refactor: move V1 code into v1/ subdirectory

- git mv cmd/ internal/ pkg/ test/ go.mod go.sum Makefile build.sh docs/ v1/
- Create v1/README.md with V1 documentation
- Update root README for V1 + V2 structure
- V1 still builds (cd v1 && go build ./cmd/hostkeeper) and 105 tests pass
- Root is now clean for V2 development
This commit is contained in:
swanadiva
2026-07-07 11:56:27 +07:00
parent 8ebdebedc8
commit 847989df75
68 changed files with 58 additions and 116 deletions
+188
View File
@@ -0,0 +1,188 @@
package config_test
import (
"os"
"path/filepath"
"testing"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
)
func tempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "config-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
return dir
}
// 4.1 New first run — creates default config
func TestNewFirstRun(t *testing.T) {
dir := tempDir(t)
// Override HOME to use temp dir
origHome := os.Getenv("HOME")
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
cfg, err := config.New()
if err != nil {
t.Fatalf("New failed: %v", err)
}
if cfg == nil {
t.Fatal("New should return non-nil Config")
}
appCfg := cfg.GetAppConfig()
if appCfg == nil {
t.Fatal("GetAppConfig should return non-nil")
}
if appCfg.Version != "1.0.0" {
t.Errorf("Version = %q, want %q", appCfg.Version, "1.0.0")
}
if appCfg.Theme != "dark" {
t.Errorf("Theme = %q, want %q", appCfg.Theme, "dark")
}
}
// 4.3 Save
func TestSave(t *testing.T) {
dir := tempDir(t)
origHome := os.Getenv("HOME")
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
cfg, _ := config.New()
appCfg := cfg.GetAppConfig()
appCfg.Theme = "light"
err := cfg.Save()
if err != nil {
t.Fatalf("Save failed: %v", err)
}
// Reload and verify
cfg2, _ := config.New()
if cfg2.GetAppConfig().Theme != "light" {
t.Errorf("After Save, Theme = %q, want %q", cfg2.GetAppConfig().Theme, "light")
}
}
// 4.4 UpdateAppConfig
func TestUpdateAppConfig(t *testing.T) {
dir := tempDir(t)
origHome := os.Getenv("HOME")
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
cfg, _ := config.New()
appCfg := cfg.GetAppConfig()
appCfg.Theme = "dracula"
appCfg.DefaultPort = 2222
err := cfg.UpdateAppConfig(appCfg)
if err != nil {
t.Fatalf("UpdateAppConfig failed: %v", err)
}
// Reload and verify
cfg2, _ := config.New()
if cfg2.GetAppConfig().Theme != "dracula" {
t.Errorf("After UpdateAppConfig, Theme = %q, want %q", cfg2.GetAppConfig().Theme, "dracula")
}
if cfg2.GetAppConfig().DefaultPort != 2222 {
t.Errorf("After UpdateAppConfig, DefaultPort = %d, want 2222", cfg2.GetAppConfig().DefaultPort)
}
}
// 4.5 GetConfigDir
func TestGetConfigDir(t *testing.T) {
dir := tempDir(t)
origHome := os.Getenv("HOME")
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
cfg, _ := config.New()
configDir := cfg.GetConfigDir()
if configDir == "" {
t.Error("GetConfigDir should return non-empty path")
}
if !filepath.IsAbs(configDir) {
t.Errorf("GetConfigDir should return absolute path, got %q", configDir)
}
}
// 4.6 GetDataDir
func TestGetDataDir(t *testing.T) {
dir := tempDir(t)
origHome := os.Getenv("HOME")
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
cfg, _ := config.New()
dataDir := cfg.GetDataDir()
if dataDir == "" {
t.Error("GetDataDir should return non-empty path")
}
if !filepath.IsAbs(dataDir) {
t.Errorf("GetDataDir should return absolute path, got %q", dataDir)
}
}
// 4.7 GetConfigFilePath
func TestGetConfigFilePath(t *testing.T) {
dir := tempDir(t)
origHome := os.Getenv("HOME")
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
cfg, _ := config.New()
path := cfg.GetConfigFilePath()
if filepath.Base(path) != "config.json" {
t.Errorf("GetConfigFilePath should end with config.json, got %q", path)
}
}
// 4.8 GetHostsFilePath
func TestGetHostsFilePath(t *testing.T) {
dir := tempDir(t)
origHome := os.Getenv("HOME")
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
cfg, _ := config.New()
path := cfg.GetHostsFilePath()
if filepath.Base(path) != "hosts.json" {
t.Errorf("GetHostsFilePath should end with hosts.json, got %q", path)
}
}
// 4.9 GetKeysFilePath
func TestGetKeysFilePath(t *testing.T) {
dir := tempDir(t)
origHome := os.Getenv("HOME")
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
cfg, _ := config.New()
path := cfg.GetKeysFilePath()
if filepath.Base(path) != "keys.json" {
t.Errorf("GetKeysFilePath should end with keys.json, got %q", path)
}
}
// 4.10 GetSnippetsFilePath
func TestGetSnippetsFilePath(t *testing.T) {
dir := tempDir(t)
origHome := os.Getenv("HOME")
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
cfg, _ := config.New()
path := cfg.GetSnippetsFilePath()
if filepath.Base(path) != "snippets.json" {
t.Errorf("GetSnippetsFilePath should end with snippets.json, got %q", path)
}
}
+236
View File
@@ -0,0 +1,236 @@
package crypto_test
import (
"bytes"
"crypto/rand"
"strings"
"testing"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/crypto"
)
// 1.1 DeriveKey determinism — same password + salt → same key
func TestDeriveKeyDeterminism(t *testing.T) {
salt := []byte("1234567890123456")
key1 := crypto.DeriveKey("password", salt)
key2 := crypto.DeriveKey("password", salt)
if !bytes.Equal(key1, key2) {
t.Error("DeriveKey should return same key for same password + salt")
}
}
// 1.2 DeriveKey password variation — different password → different key
func TestDeriveKeyPasswordVariation(t *testing.T) {
salt := []byte("1234567890123456")
key1 := crypto.DeriveKey("password1", salt)
key2 := crypto.DeriveKey("password2", salt)
if bytes.Equal(key1, key2) {
t.Error("DeriveKey should return different keys for different passwords")
}
}
// 1.3 DeriveKey salt variation — different salt → different key
func TestDeriveKeySaltVariation(t *testing.T) {
key1 := crypto.DeriveKey("password", []byte("1234567890123456"))
key2 := crypto.DeriveKey("password", []byte("6543210987654321"))
if bytes.Equal(key1, key2) {
t.Error("DeriveKey should return different keys for different salts")
}
}
// 1.4 DeriveKey empty password — no panic, valid key length
func TestDeriveKeyEmptyPassword(t *testing.T) {
salt := []byte("1234567890123456")
key := crypto.DeriveKey("", salt)
if len(key) != crypto.KeyLength {
t.Errorf("DeriveKey with empty password should return %d bytes, got %d", crypto.KeyLength, len(key))
}
}
// 1.5 Encrypt/Decrypt round-trip
func TestEncryptDecryptRoundTrip(t *testing.T) {
plaintext := []byte("hello world")
encoded, err := crypto.Encrypt(plaintext, "mypassword")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decoded, err := crypto.Decrypt(encoded, "mypassword")
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if !bytes.Equal(plaintext, decoded) {
t.Errorf("Round-trip failed: got %q, want %q", decoded, plaintext)
}
}
// 1.6 Encrypt empty plaintext
func TestEncryptDecryptEmpty(t *testing.T) {
plaintext := []byte("")
encoded, err := crypto.Encrypt(plaintext, "password")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decoded, err := crypto.Decrypt(encoded, "password")
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if len(decoded) != 0 {
t.Errorf("Expected empty plaintext, got %d bytes", len(decoded))
}
}
// 1.7 Encrypt large data (1MB)
func TestEncryptDecryptLargeData(t *testing.T) {
plaintext := make([]byte, 1024*1024)
if _, err := rand.Read(plaintext); err != nil {
t.Fatalf("Failed to generate random data: %v", err)
}
encoded, err := crypto.Encrypt(plaintext, "password")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decoded, err := crypto.Decrypt(encoded, "password")
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if !bytes.Equal(plaintext, decoded) {
t.Error("Large data round-trip failed")
}
}
// 1.8 Encrypt unicode
func TestEncryptDecryptUnicode(t *testing.T) {
plaintext := []byte("こんにちは世界 🌍")
encoded, err := crypto.Encrypt(plaintext, "password")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decoded, err := crypto.Decrypt(encoded, "password")
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if !bytes.Equal(plaintext, decoded) {
t.Errorf("Unicode round-trip failed: got %q, want %q", decoded, plaintext)
}
}
// 1.9 Encrypt with newlines
func TestEncryptDecryptNewlines(t *testing.T) {
plaintext := []byte("line1\nline2\nline3")
encoded, err := crypto.Encrypt(plaintext, "password")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decoded, err := crypto.Decrypt(encoded, "password")
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if !bytes.Equal(plaintext, decoded) {
t.Errorf("Newlines round-trip failed: got %q, want %q", decoded, plaintext)
}
}
// 1.10 Wrong password → error
func TestDecryptWrongPassword(t *testing.T) {
plaintext := []byte("secret data")
encoded, err := crypto.Encrypt(plaintext, "correct-password")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
_, err = crypto.Decrypt(encoded, "wrong-password")
if err == nil {
t.Error("Decrypt with wrong password should return error")
}
}
// 1.11 Empty password → error
func TestDecryptEmptyPassword(t *testing.T) {
plaintext := []byte("secret data")
encoded, err := crypto.Encrypt(plaintext, "password")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
_, err = crypto.Decrypt(encoded, "")
if err == nil {
t.Error("Decrypt with empty password should return error")
}
}
// 1.12 IsEncrypted valid ciphertext
func TestIsEncryptedValid(t *testing.T) {
encoded, err := crypto.Encrypt([]byte("test"), "password")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
if !crypto.IsEncrypted(encoded) {
t.Error("IsEncrypted should return true for valid ciphertext")
}
}
// 1.13 IsEncrypted plaintext
func TestIsEncryptedPlaintext(t *testing.T) {
if crypto.IsEncrypted("hello world") {
t.Error("IsEncrypted should return false for plaintext")
}
}
// 1.14 IsEncrypted empty
func TestIsEncryptedEmpty(t *testing.T) {
if crypto.IsEncrypted("") {
t.Error("IsEncrypted should return false for empty string")
}
}
// 1.15 HashPassword determinism
func TestHashPasswordDeterminism(t *testing.T) {
hash1 := crypto.HashPassword("mypassword")
hash2 := crypto.HashPassword("mypassword")
if hash1 != hash2 {
t.Error("HashPassword should return same hash for same password")
}
}
// 1.16 HashPassword variation
func TestHashPasswordVariation(t *testing.T) {
hash1 := crypto.HashPassword("password1")
hash2 := crypto.HashPassword("password2")
if hash1 == hash2 {
t.Error("HashPassword should return different hashes for different passwords")
}
}
// 1.17 Encrypt randomness — same input → different ciphertext
func TestEncryptRandomness(t *testing.T) {
plaintext := []byte("same input")
encoded1, _ := crypto.Encrypt(plaintext, "password")
encoded2, _ := crypto.Encrypt(plaintext, "password")
if encoded1 == encoded2 {
t.Error("Encrypt should produce different ciphertext each time (random salt)")
}
}
// Verify constants
func TestConstants(t *testing.T) {
if crypto.KeyLength != 32 {
t.Errorf("KeyLength = %d, want 32", crypto.KeyLength)
}
if crypto.SaltLength != 16 {
t.Errorf("SaltLength = %d, want 16", crypto.SaltLength)
}
if crypto.Iterations != 100000 {
t.Errorf("Iterations = %d, want 100000", crypto.Iterations)
}
}
// Verify error variables
func TestErrorVars(t *testing.T) {
if crypto.ErrInvalidPassword == nil {
t.Error("ErrInvalidPassword should not be nil")
}
if crypto.ErrDecryptionFailed == nil {
t.Error("ErrDecryptionFailed should not be nil")
}
if !strings.Contains(crypto.ErrDecryptionFailed.Error(), "decryption failed") {
t.Error("ErrDecryptionFailed message should contain 'decryption failed'")
}
}
+142
View File
@@ -0,0 +1,142 @@
package errors_test
import (
stderrors "errors"
"strings"
"testing"
apperrors "git.tukangketik.id/swanadiva/hostkeeper/internal/errors"
)
func TestAppError(t *testing.T) {
cause := stderrors.New("underlying issue")
appErr := apperrors.NewAppError(
apperrors.ErrAuthFailed,
"Authentication failed",
cause,
[]string{"Check credentials", "Verify key permissions"},
)
if appErr.Code != apperrors.ErrAuthFailed {
t.Errorf("Expected code '%s', got '%s'", apperrors.ErrAuthFailed, appErr.Code)
}
if len(appErr.Hints) != 2 {
t.Errorf("Expected 2 hints, got %d", len(appErr.Hints))
}
// Test error message is non-empty
if appErr.Error() == "" {
t.Error("Expected non-empty error message")
}
// Test Unwrap
if !stderrors.Is(appErr, cause) {
t.Error("Expected errors.Is to match the cause via Unwrap")
}
}
func TestConnectionError(t *testing.T) {
connErr := apperrors.NewConnectionError(
"auth",
"Authentication failed",
"ssh: handshake failed",
[]string{"Check credentials", "Verify key permissions"},
)
if connErr.Type != "auth" {
t.Errorf("Expected type 'auth', got '%s'", connErr.Type)
}
if len(connErr.Hints) != 2 {
t.Errorf("Expected 2 hints, got %d", len(connErr.Hints))
}
if connErr.Error() == "" {
t.Error("Expected non-empty error message")
}
}
func TestHandleSSHError(t *testing.T) {
tests := []struct {
name string
inputErr error
expectedType string
}{
{
name: "connection refused",
inputErr: stderrors.New("dial tcp: connection refused"),
expectedType: "network",
},
{
name: "authentication failed",
inputErr: stderrors.New("ssh: handshake failed: ssh: unable to authenticate"),
expectedType: "auth",
},
{
name: "timeout",
inputErr: stderrors.New("dial tcp: connection timed out"),
expectedType: "timeout",
},
{
name: "no such host",
inputErr: stderrors.New("dial tcp: lookup: no such host"),
expectedType: "config",
},
{
name: "permission denied",
inputErr: stderrors.New("ssh: permission denied"),
expectedType: "auth",
},
{
name: "unknown error",
inputErr: stderrors.New("something went wrong"),
expectedType: "unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connErr := apperrors.HandleSSHError(tt.inputErr)
if connErr == nil {
t.Fatal("Expected non-nil ConnectionError")
}
if connErr.Type != tt.expectedType {
t.Errorf("Expected type '%s', got '%s'", tt.expectedType, connErr.Type)
}
if len(connErr.Hints) == 0 {
t.Error("Expected at least one hint")
}
})
}
}
func TestHandleSSHErrorNil(t *testing.T) {
result := apperrors.HandleSSHError(nil)
if result != nil {
t.Error("Expected nil for nil input")
}
}
func TestFormatConnectionError(t *testing.T) {
connErr := apperrors.NewConnectionError(
"auth",
"Authentication failed",
"ssh: handshake failed",
[]string{"Check credentials", "Verify key permissions"},
)
output := apperrors.FormatConnectionError(connErr)
if output == "" {
t.Error("Expected non-empty formatted output")
}
if !strings.Contains(output, "Authentication failed") {
t.Error("Expected output to contain error message")
}
if !strings.Contains(output, "Possible solutions") {
t.Error("Expected output to contain hints section")
}
}
+153
View File
@@ -0,0 +1,153 @@
package integration
import (
"context"
"testing"
"time"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/config"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
func TestIntegrationWorkflow(t *testing.T) {
tempDir := t.TempDir()
store, err := storage.NewJSONStorage(tempDir)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
ctx := context.Background()
// Test 1: Add hosts
t.Run("AddHosts", func(t *testing.T) {
testHost := &models.Host{
ID: "integration-test-1",
Name: "Integration Test Server",
Hostname: "test.example.com",
Port: 22,
Username: "testuser",
Auth: models.AuthConfig{Type: "password", Password: "testpass"},
Tags: []string{"test", "integration"},
CreatedAt: time.Now(),
}
if err := store.SaveHost(ctx, testHost); err != nil {
t.Errorf("Failed to save host: %v", err)
}
})
// Test 2: List hosts
t.Run("ListHosts", func(t *testing.T) {
hosts, err := store.ListHosts(ctx)
if err != nil {
t.Errorf("Failed to list hosts: %v", err)
}
if len(hosts) != 1 {
t.Errorf("Expected 1 host, got %d", len(hosts))
}
})
// Test 3: Get host
t.Run("GetHost", func(t *testing.T) {
host, err := store.GetHost(ctx, "integration-test-1")
if err != nil {
t.Errorf("Failed to get host: %v", err)
}
if host.Name != "Integration Test Server" {
t.Errorf("Expected name 'Integration Test Server', got '%s'", host.Name)
}
})
// Test 4: Update host
t.Run("UpdateHost", func(t *testing.T) {
host, err := store.GetHost(ctx, "integration-test-1")
if err != nil {
t.Errorf("Failed to get host: %v", err)
}
host.Name = "Updated Test Server"
if err := store.SaveHost(ctx, host); err != nil {
t.Errorf("Failed to update host: %v", err)
}
updated, err := store.GetHost(ctx, "integration-test-1")
if err != nil {
t.Errorf("Failed to get updated host: %v", err)
}
if updated.Name != "Updated Test Server" {
t.Errorf("Update failed: expected 'Updated Test Server', got '%s'", updated.Name)
}
})
// Test 5: Export/Import
t.Run("ExportImport", func(t *testing.T) {
exportData, err := store.ExportData(ctx)
if err != nil {
t.Errorf("Failed to export: %v", err)
}
if exportData == nil {
t.Error("Exported data is nil")
return
}
if len(exportData.Hosts) != 1 {
t.Errorf("Expected 1 host in export, got %d", len(exportData.Hosts))
}
importDir := t.TempDir()
importStore, err := storage.NewJSONStorage(importDir)
if err != nil {
t.Errorf("Failed to create import storage: %v", err)
}
if err := importStore.ImportData(ctx, exportData, storage.MergeStrategyReplace); err != nil {
t.Errorf("Failed to import: %v", err)
}
importedHosts, err := importStore.ListHosts(ctx)
if err != nil {
t.Errorf("Failed to list imported hosts: %v", err)
}
if len(importedHosts) != 1 {
t.Errorf("Expected 1 imported host, got %d", len(importedHosts))
}
})
// Test 6: Delete host
t.Run("DeleteHost", func(t *testing.T) {
if err := store.DeleteHost(ctx, "integration-test-1"); err != nil {
t.Errorf("Failed to delete host: %v", err)
}
hosts, err := store.ListHosts(ctx)
if err != nil {
t.Errorf("Failed to list hosts after deletion: %v", err)
}
if len(hosts) != 0 {
t.Errorf("Expected 0 hosts after deletion, got %d", len(hosts))
}
})
}
func TestConfigIntegration(t *testing.T) {
cfg, err := config.New()
if err != nil {
t.Errorf("Failed to load/create config: %v", err)
}
if cfg.GetAppConfig().DefaultPort != 22 {
t.Errorf("Expected default port 22, got %d", cfg.GetAppConfig().DefaultPort)
}
if cfg.GetAppConfig().ConnectionTimeout != 30 {
t.Errorf("Expected default timeout 30, got %d", cfg.GetAppConfig().ConnectionTimeout)
}
}
+288
View File
@@ -0,0 +1,288 @@
package knownhosts_test
import (
"crypto/ed25519"
"crypto/rand"
"os"
"path/filepath"
"testing"
cryptossh "golang.org/x/crypto/ssh"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/knownhosts"
)
func tempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "knownhosts-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
return dir
}
func generateTestKey(t *testing.T) cryptossh.PublicKey {
t.Helper()
pubKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
sshPubKey, err := cryptossh.NewPublicKey(pubKey)
if err != nil {
t.Fatalf("Failed to create SSH public key: %v", err)
}
return sshPubKey
}
func generateTestKey2(t *testing.T) cryptossh.PublicKey {
t.Helper()
pubKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
sshPubKey, err := cryptossh.NewPublicKey(pubKey)
if err != nil {
t.Fatalf("Failed to create SSH public key: %v", err)
}
return sshPubKey
}
// 2.1 New creates file on first call
func TestNewCreatesFile(t *testing.T) {
dir := tempDir(t)
kh, err := knownhosts.New(dir)
if err != nil {
t.Fatalf("New failed: %v", err)
}
if kh == nil {
t.Fatal("New should return non-nil KnownHosts")
}
// File should be created after first Add+Save
}
// 2.2 New loads existing
func TestNewLoadsExisting(t *testing.T) {
dir := tempDir(t)
key := generateTestKey(t)
// Create and add a host
kh1, _ := knownhosts.New(dir)
_ = kh1.Add("example.com", 22, key)
// Load again
kh2, err := knownhosts.New(dir)
if err != nil {
t.Fatalf("New failed: %v", err)
}
stored := kh2.Get("example.com", 22)
if stored == nil {
t.Fatal("Should load existing host from file")
}
}
// 2.3 Add new host
func TestAddNewHost(t *testing.T) {
dir := tempDir(t)
key := generateTestKey(t)
kh, _ := knownhosts.New(dir)
err := kh.Add("example.com", 22, key)
if err != nil {
t.Fatalf("Add failed: %v", err)
}
stored := kh.Get("example.com", 22)
if stored == nil {
t.Fatal("Get should return the added host")
}
if stored.Hostname != "example.com" {
t.Errorf("Hostname = %q, want %q", stored.Hostname, "example.com")
}
if stored.Port != 22 {
t.Errorf("Port = %d, want 22", stored.Port)
}
}
// 2.4 Add duplicate — no error, no duplicate
func TestAddDuplicate(t *testing.T) {
dir := tempDir(t)
key := generateTestKey(t)
kh, _ := knownhosts.New(dir)
_ = kh.Add("example.com", 22, key)
err := kh.Add("example.com", 22, key)
if err != nil {
t.Fatalf("Add duplicate should not error: %v", err)
}
}
// 2.5 Get existing host
func TestGetExisting(t *testing.T) {
dir := tempDir(t)
key := generateTestKey(t)
kh, _ := knownhosts.New(dir)
_ = kh.Add("example.com", 22, key)
stored := kh.Get("example.com", 22)
if stored == nil {
t.Fatal("Get should return existing host")
}
if stored.Hostname != "example.com" {
t.Errorf("Hostname = %q, want %q", stored.Hostname, "example.com")
}
}
// 2.6 Get non-existent host
func TestGetNonExistent(t *testing.T) {
dir := tempDir(t)
kh, _ := knownhosts.New(dir)
stored := kh.Get("unknown.com", 22)
if stored != nil {
t.Error("Get should return nil for non-existent host")
}
}
// 2.7 Remove existing host
func TestRemoveExisting(t *testing.T) {
dir := tempDir(t)
key := generateTestKey(t)
kh, _ := knownhosts.New(dir)
_ = kh.Add("example.com", 22, key)
err := kh.Remove("example.com", 22)
if err != nil {
t.Fatalf("Remove failed: %v", err)
}
stored := kh.Get("example.com", 22)
if stored != nil {
t.Error("Get should return nil after Remove")
}
}
// 2.8 Remove non-existent host — no error
func TestRemoveNonExistent(t *testing.T) {
dir := tempDir(t)
kh, _ := knownhosts.New(dir)
err := kh.Remove("unknown.com", 22)
if err != nil {
t.Fatalf("Remove non-existent should not error: %v", err)
}
}
// 2.9 Verify unknown host — (false, nil) TOFU
func TestVerifyUnknown(t *testing.T) {
dir := tempDir(t)
key := generateTestKey(t)
kh, _ := knownhosts.New(dir)
matches, stored := kh.Verify("unknown.com", 22, key)
if matches {
t.Error("Verify should return false for unknown host")
}
if stored != nil {
t.Error("Verify should return nil HostKey for unknown host")
}
}
// 2.10 Verify known host, matching key — (true, hostKey)
func TestVerifyKnownMatch(t *testing.T) {
dir := tempDir(t)
key := generateTestKey(t)
kh, _ := knownhosts.New(dir)
_ = kh.Add("example.com", 22, key)
matches, stored := kh.Verify("example.com", 22, key)
if !matches {
t.Error("Verify should return true for matching key")
}
if stored == nil {
t.Error("Verify should return stored HostKey")
}
}
// 2.11 Verify known host, mismatched key — (false, hostKey) MITM
func TestVerifyKnownMismatch(t *testing.T) {
dir := tempDir(t)
key1 := generateTestKey(t)
key2 := generateTestKey2(t)
kh, _ := knownhosts.New(dir)
_ = kh.Add("example.com", 22, key1)
matches, stored := kh.Verify("example.com", 22, key2)
if matches {
t.Error("Verify should return false for mismatched key (MITM)")
}
if stored == nil {
t.Error("Verify should return stored HostKey for mismatch")
}
}
// 2.12 HostKeyCallback — autoAdd=true adds unknown hosts
func TestHostKeyCallbackAutoAdd(t *testing.T) {
dir := tempDir(t)
key := generateTestKey(t)
kh, _ := knownhosts.New(dir)
callback := kh.HostKeyCallback(true)
// Simulate host key check via callback
// HostKeyCallback expects net.Addr, so we create a fake one
addr := &fakeAddr{addr: "192.168.1.1:22"}
err := callback("example.com", addr, key)
if err != nil {
t.Fatalf("HostKeyCallback with autoAdd failed: %v", err)
}
// Verify host was added
stored := kh.Get("example.com", 22)
if stored == nil {
t.Error("HostKeyCallback should add unknown host when autoAdd=true")
}
}
// 2.13 Persistence — Add → Save → New → Get
func TestPersistence(t *testing.T) {
dir := tempDir(t)
key := generateTestKey(t)
kh1, _ := knownhosts.New(dir)
_ = kh1.Add("example.com", 22, key)
// Create new instance from same directory
kh2, err := knownhosts.New(dir)
if err != nil {
t.Fatalf("New failed: %v", err)
}
stored := kh2.Get("example.com", 22)
if stored == nil {
t.Fatal("Host should persist across New() calls")
}
}
// 2.14 Corrupted file → error
func TestCorruptedFile(t *testing.T) {
dir := tempDir(t)
path := filepath.Join(dir, "known_hosts")
_ = os.WriteFile(path, []byte("not valid json {{{"), 0600)
_, err := knownhosts.New(dir)
if err == nil {
t.Error("New should return error for corrupted file")
}
}
// fakeAddr implements net.Addr for testing
type fakeAddr struct {
addr string
}
func (f *fakeAddr) Network() string { return "tcp" }
func (f *fakeAddr) String() string { return f.addr }
+80
View File
@@ -0,0 +1,80 @@
package errors_test
import (
"testing"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
)
func TestDefaultConfig(t *testing.T) {
config := models.DefaultConfig()
if config.Version != "1.0.0" {
t.Errorf("Version = %q, want %q", config.Version, "1.0.0")
}
if config.DefaultPort != 22 {
t.Errorf("DefaultPort = %d, want 22", config.DefaultPort)
}
if config.ConnectionTimeout != 30 {
t.Errorf("ConnectionTimeout = %d, want 30", config.ConnectionTimeout)
}
if config.Theme != "dark" {
t.Errorf("Theme = %q, want %q", config.Theme, "dark")
}
if len(config.Profiles) != 1 {
t.Errorf("Profiles has %d items, want 1", len(config.Profiles))
}
if config.ActiveProfile != "default" {
t.Errorf("ActiveProfile = %q, want %q", config.ActiveProfile, "default")
}
}
func TestAppConfigProfiles(t *testing.T) {
config := models.DefaultConfig()
// Test GetProfile
profile := config.GetProfile("default")
if profile == nil {
t.Fatal("GetProfile(default) returned nil")
}
if profile.Name != "default" {
t.Errorf("Profile.Name = %q, want %q", profile.Name, "default")
}
// Test GetProfile for non-existent profile
profile = config.GetProfile("nonexistent")
if profile != nil {
t.Error("GetProfile(nonexistent) should return nil")
}
// Test GetActiveProfile
profile = config.GetActiveProfile()
if profile == nil {
t.Fatal("GetActiveProfile() returned nil")
}
if profile.Name != "default" {
t.Errorf("Active profile Name = %q, want %q", profile.Name, "default")
}
// Test AddProfile
newProfile := models.Profile{
Name: "work",
Theme: "light",
}
config.AddProfile(newProfile)
if len(config.Profiles) != 2 {
t.Errorf("After AddProfile, Profiles has %d items, want 2", len(config.Profiles))
}
// Test RemoveProfile
config.RemoveProfile("work")
if len(config.Profiles) != 1 {
t.Errorf("After RemoveProfile, Profiles has %d items, want 1", len(config.Profiles))
}
// Test RemoveProfile for non-existent profile
config.RemoveProfile("nonexistent")
if len(config.Profiles) != 1 {
t.Errorf("After RemoveProfile(nonexistent), Profiles has %d items, want 1", len(config.Profiles))
}
}
+106
View File
@@ -0,0 +1,106 @@
package ssh_test
import (
"context"
"testing"
"time"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh"
)
func TestNewClient(t *testing.T) {
host := &models.Host{
ID: "test-host",
Name: "Test Server",
Hostname: "localhost",
Port: 22,
Username: "testuser",
Auth: models.AuthConfig{
Type: "password",
Password: "testpass",
},
}
client := ssh.NewClient(host, 30*time.Second)
if client == nil {
t.Fatal("Failed to create SSH client")
}
if client.IsConnected() {
t.Error("Expected client to not be connected initially")
}
// Close should be safe even when not connected
if err := client.Close(); err != nil {
t.Errorf("Expected nil error on close when not connected, got %v", err)
}
}
func TestConnectFailure(t *testing.T) {
// Test connecting to a non-existent server
host := &models.Host{
ID: "test-host",
Name: "Non-existent Server",
Hostname: "127.0.0.1",
Port: 9999, // Port that's likely not running SSH
Username: "testuser",
Auth: models.AuthConfig{
Type: "password",
Password: "testpass",
},
}
client := ssh.NewClient(host, 2*time.Second)
ctx := context.Background()
err := client.Connect(ctx)
// We expect connection to fail
if err == nil {
t.Log("Connection succeeded (unexpected - SSH server may be running on port 9999)")
_ = client.Close()
} else {
t.Logf("Connection failed as expected: %v", err)
}
}
func TestExecuteWithoutConnection(t *testing.T) {
host := &models.Host{
ID: "test-host",
Name: "Test Server",
Hostname: "localhost",
Port: 22,
Username: "testuser",
Auth: models.AuthConfig{
Type: "password",
Password: "testpass",
},
}
client := ssh.NewClient(host, 30*time.Second)
ctx := context.Background()
_, err := client.Execute(ctx, "echo hello")
if err == nil {
t.Error("Expected error when executing command without connection")
}
}
func TestGetClient(t *testing.T) {
host := &models.Host{
ID: "test-host",
Name: "Test Server",
Hostname: "localhost",
Port: 22,
Username: "testuser",
Auth: models.AuthConfig{
Type: "password",
Password: "testpass",
},
}
client := ssh.NewClient(host, 30*time.Second)
if client.GetClient() != nil {
t.Error("Expected nil underlying client before connecting")
}
}
+83
View File
@@ -0,0 +1,83 @@
package storage_test
import (
"context"
"encoding/json"
"testing"
"time"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
func TestExportImport(t *testing.T) {
tempDir := t.TempDir()
store, err := storage.NewJSONStorage(tempDir)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
ctx := context.Background()
testHost := &models.Host{
ID: "test-host-1",
Name: "Test Server",
Hostname: "192.168.1.100",
Port: 22,
Username: "admin",
Auth: models.AuthConfig{Type: "password"},
CreatedAt: time.Now(),
}
if err := store.SaveHost(ctx, testHost); err != nil {
t.Fatalf("Failed to save host: %v", err)
}
exportedData, err := store.ExportData(ctx)
if err != nil {
t.Fatalf("Failed to export data: %v", err)
}
if exportedData == nil {
t.Fatal("Exported data is nil")
}
if len(exportedData.Hosts) != 1 {
t.Fatalf("Expected 1 host, got %d", len(exportedData.Hosts))
}
// Simulate writing to file and reading back
jsonBytes, err := json.Marshal(exportedData)
if err != nil {
t.Fatalf("Failed to marshal export data: %v", err)
}
var importedData storage.ExportData
if err := json.Unmarshal(jsonBytes, &importedData); err != nil {
t.Fatalf("Failed to unmarshal export data: %v", err)
}
importDir := t.TempDir()
importStore, err := storage.NewJSONStorage(importDir)
if err != nil {
t.Fatalf("Failed to create import storage: %v", err)
}
if err := importStore.ImportData(ctx, &importedData, storage.MergeStrategyReplace); err != nil {
t.Fatalf("Failed to import data: %v", err)
}
importedHosts, err := importStore.ListHosts(ctx)
if err != nil {
t.Fatalf("Failed to list imported hosts: %v", err)
}
if len(importedHosts) != 1 {
t.Errorf("Expected 1 imported host, got %d", len(importedHosts))
}
if importedHosts[0].Name != testHost.Name {
t.Errorf("Expected host name '%s', got '%s'", testHost.Name, importedHosts[0].Name)
}
}
+392
View File
@@ -0,0 +1,392 @@
package storage_test
import (
"context"
"os"
"path/filepath"
"testing"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
)
func tempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "storage-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
return dir
}
// ============ KeyPair CRUD ============
// 3.1 SaveKeyPair
func TestSaveKeyPair(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
kp := &models.KeyPair{
Name: "my-key",
Type: "ed25519",
PrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
}
err := store.SaveKeyPair(ctx, kp)
if err != nil {
t.Fatalf("SaveKeyPair failed: %v", err)
}
if kp.ID == "" {
t.Error("SaveKeyPair should generate UUID")
}
}
// 3.2 ListKeyPairs
func TestListKeyPairs(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
kp := &models.KeyPair{Name: "key1", Type: "ed25519", PrivateKey: "test-key-data"}
_ = store.SaveKeyPair(ctx, kp)
keys, err := store.ListKeyPairs(ctx)
if err != nil {
t.Fatalf("ListKeyPairs failed: %v", err)
}
if len(keys) != 1 {
t.Errorf("ListKeyPairs returned %d keys, want 1", len(keys))
}
}
// 3.3 GetKeyPair found
func TestGetKeyPairFound(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
kp := &models.KeyPair{Name: "key1", Type: "ed25519", PrivateKey: "test-key-data"}
_ = store.SaveKeyPair(ctx, kp)
found, err := store.GetKeyPair(ctx, kp.ID)
if err != nil {
t.Fatalf("GetKeyPair failed: %v", err)
}
if found.Name != "key1" {
t.Errorf("Name = %q, want %q", found.Name, "key1")
}
}
// 3.4 GetKeyPair not found
func TestGetKeyPairNotFound(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
_, err := store.GetKeyPair(ctx, "nonexistent")
if err == nil {
t.Error("GetKeyPair should return error for non-existent ID")
}
}
// 3.5 DeleteKeyPair
func TestDeleteKeyPair(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
kp := &models.KeyPair{Name: "key1", Type: "ed25519", PrivateKey: "test-key-data"}
_ = store.SaveKeyPair(ctx, kp)
err := store.DeleteKeyPair(ctx, kp.ID)
if err != nil {
t.Fatalf("DeleteKeyPair failed: %v", err)
}
keys, _ := store.ListKeyPairs(ctx)
if len(keys) != 0 {
t.Errorf("ListKeyPairs after delete returned %d keys, want 0", len(keys))
}
}
// 3.6 DeleteKeyPair not found
func TestDeleteKeyPairNotFound(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
err := store.DeleteKeyPair(ctx, "nonexistent")
if err == nil {
t.Error("DeleteKeyPair should return error for non-existent ID")
}
}
// ============ Snippet CRUD ============
// 3.7 SaveSnippet
func TestSaveSnippet(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
snippet := &models.Snippet{
Name: "deploy-script",
Command: "deploy.sh",
Description: "deployment script",
}
err := store.SaveSnippet(ctx, snippet)
if err != nil {
t.Fatalf("SaveSnippet failed: %v", err)
}
if snippet.ID == "" {
t.Error("SaveSnippet should generate UUID")
}
}
// 3.8 ListSnippets
func TestListSnippets(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
snippet := &models.Snippet{Name: "s1", Command: "cmd1", Description: "desc1"}
_ = store.SaveSnippet(ctx, snippet)
snippets, err := store.ListSnippets(ctx)
if err != nil {
t.Fatalf("ListSnippets failed: %v", err)
}
if len(snippets) != 1 {
t.Errorf("ListSnippets returned %d snippets, want 1", len(snippets))
}
}
// 3.9 GetSnippet found
func TestGetSnippetFound(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
snippet := &models.Snippet{Name: "s1", Command: "cmd1", Description: "desc1"}
_ = store.SaveSnippet(ctx, snippet)
found, err := store.GetSnippet(ctx, snippet.ID)
if err != nil {
t.Fatalf("GetSnippet failed: %v", err)
}
if found.Name != "s1" {
t.Errorf("Name = %q, want %q", found.Name, "s1")
}
}
// 3.10 GetSnippet not found
func TestGetSnippetNotFound(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
_, err := store.GetSnippet(ctx, "nonexistent")
if err == nil {
t.Error("GetSnippet should return error for non-existent ID")
}
}
// 3.11 DeleteSnippet
func TestDeleteSnippet(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
snippet := &models.Snippet{Name: "s1", Command: "cmd1", Description: "desc1"}
_ = store.SaveSnippet(ctx, snippet)
err := store.DeleteSnippet(ctx, snippet.ID)
if err != nil {
t.Fatalf("DeleteSnippet failed: %v", err)
}
snippets, _ := store.ListSnippets(ctx)
if len(snippets) != 0 {
t.Errorf("ListSnippets after delete returned %d snippets, want 0", len(snippets))
}
}
// 3.12 DeleteSnippet not found
func TestDeleteSnippetNotFound(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
err := store.DeleteSnippet(ctx, "nonexistent")
if err == nil {
t.Error("DeleteSnippet should return error for non-existent ID")
}
}
// ============ Encryption ============
// 3.13 SetPassword + SaveHost → encrypted on disk
func TestEncryptionSaveHost(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
store.SetPassword("test-password-123")
ctx := context.Background()
host := &models.Host{
Name: "encrypted-host",
Hostname: "192.168.1.100",
Port: 22,
Username: "admin",
}
err := store.SaveHost(ctx, host)
if err != nil {
t.Fatalf("SaveHost with encryption failed: %v", err)
}
// Read raw file — should be base64 ciphertext
data, _ := os.ReadFile(filepath.Join(dir, "hosts.json"))
if string(data) == "" {
t.Fatal("hosts.json should not be empty")
}
}
// 3.14 IsDataEncrypted
func TestIsDataEncrypted(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
// Before encryption — not encrypted
if store.IsDataEncrypted() {
t.Error("IsDataEncrypted should be false before SetPassword")
}
// After encryption
store.SetPassword("test-password-123")
ctx := context.Background()
host := &models.Host{Name: "h1", Hostname: "1.2.3.4", Port: 22, Username: "u"}
_ = store.SaveHost(ctx, host)
if !store.IsDataEncrypted() {
t.Error("IsDataEncrypted should be true after SaveHost with password")
}
}
// 3.15 Wrong password → error on load
func TestEncryptionWrongPassword(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
store.SetPassword("correct-password")
ctx := context.Background()
host := &models.Host{Name: "h1", Hostname: "1.2.3.4", Port: 22, Username: "u"}
_ = store.SaveHost(ctx, host)
// Try loading with wrong password
store2, _ := storage.NewJSONStorage(dir)
store2.SetPassword("wrong-password")
_, err := store2.ListHosts(ctx)
if err == nil {
t.Error("ListHosts with wrong password should return error")
}
}
// ============ MergeStrategy ============
// 3.16 MergeStrategyMerge
func TestMergeStrategyMerge(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
// Add existing host
existing := &models.Host{ID: "host-1", Name: "existing", Hostname: "1.1.1.1", Port: 22, Username: "u"}
_ = store.SaveHost(ctx, existing)
// Import with merge — new host should be added, existing kept
importData := &storage.ExportData{
Hosts: []*models.Host{
{ID: "host-2", Name: "imported", Hostname: "2.2.2.2", Port: 22, Username: "u"},
},
}
err := store.ImportData(ctx, importData, storage.MergeStrategyMerge)
if err != nil {
t.Fatalf("ImportData with merge failed: %v", err)
}
hosts, _ := store.ListHosts(ctx)
if len(hosts) != 2 {
t.Errorf("After merge, got %d hosts, want 2", len(hosts))
}
}
// 3.17 MergeStrategyReplace
func TestMergeStrategyReplace(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
// Add existing host
existing := &models.Host{ID: "host-1", Name: "existing", Hostname: "1.1.1.1", Port: 22, Username: "u"}
_ = store.SaveHost(ctx, existing)
// Import with replace — existing should be overwritten
importData := &storage.ExportData{
Hosts: []*models.Host{
{ID: "host-2", Name: "new", Hostname: "2.2.2.2", Port: 22, Username: "u"},
},
}
err := store.ImportData(ctx, importData, storage.MergeStrategyReplace)
if err != nil {
t.Fatalf("ImportData with replace failed: %v", err)
}
hosts, _ := store.ListHosts(ctx)
if len(hosts) != 1 {
t.Errorf("After replace, got %d hosts, want 1", len(hosts))
}
if hosts[0].ID != "host-2" {
t.Errorf("After replace, host ID = %q, want %q", hosts[0].ID, "host-2")
}
}
// ============ Edge Cases ============
// 3.18 SaveHost empty ID → generates UUID
func TestSaveHostEmptyID(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
ctx := context.Background()
host := &models.Host{Name: "no-id", Hostname: "1.2.3.4", Port: 22, Username: "u"}
err := store.SaveHost(ctx, host)
if err != nil {
t.Fatalf("SaveHost failed: %v", err)
}
if host.ID == "" {
t.Error("SaveHost should generate UUID for empty ID")
}
}
// GetPassword / IsEncrypted
func TestPasswordMethods(t *testing.T) {
dir := tempDir(t)
store, _ := storage.NewJSONStorage(dir)
if store.IsEncrypted() {
t.Error("IsEncrypted should be false initially")
}
if store.GetPassword() != "" {
t.Error("GetPassword should be empty initially")
}
store.SetPassword("test123")
if !store.IsEncrypted() {
t.Error("IsEncrypted should be true after SetPassword")
}
if store.GetPassword() != "test123" {
t.Errorf("GetPassword = %q, want %q", store.GetPassword(), "test123")
}
}
+95
View File
@@ -0,0 +1,95 @@
package tui_test
import (
"testing"
"time"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/tui"
)
func TestErrorBannerShowHide(t *testing.T) {
banner := tui.NewErrorBanner(tui.SevError)
// Initially not visible
if banner.IsVisible() {
t.Error("Banner should not be visible initially")
}
// Show the banner
banner.Show("Test Error", "Something went wrong", "Check logs", "Restart app")
if !banner.IsVisible() {
t.Error("Banner should be visible after Show()")
}
// Verify content
if banner.Title != "Test Error" {
t.Errorf("Title = %q, want %q", banner.Title, "Test Error")
}
if banner.Detail != "Something went wrong" {
t.Errorf("Detail = %q, want %q", banner.Detail, "Something went wrong")
}
if len(banner.Hints) != 2 {
t.Errorf("Hints has %d items, want 2", len(banner.Hints))
}
// Hide the banner
banner.Hide()
if banner.IsVisible() {
t.Error("Banner should not be visible after Hide()")
}
}
func TestErrorBannerAutoDismiss(t *testing.T) {
banner := tui.NewErrorBanner(tui.SevWarning)
banner.AutoDismiss = true
banner.DismissAfter = 100 * time.Millisecond
banner.Show("Test Warning", "Something")
if !banner.IsVisible() {
t.Error("Banner should be visible after Show()")
}
// Wait for auto-dismiss
time.Sleep(150 * time.Millisecond)
banner.Update()
if banner.IsVisible() {
t.Error("Banner should be auto-dismissed after delay")
}
}
func TestErrorBannerSeverity(t *testing.T) {
tests := []struct {
severity tui.ErrorSeverity
name string
}{
{tui.SevError, "error"},
{tui.SevWarning, "warning"},
{tui.SevInfo, "info"},
}
for _, tt := range tests {
banner := tui.NewErrorBanner(tt.severity)
if banner.Severity != tt.severity {
t.Errorf("NewErrorBanner(%v).Severity = %v, want %v", tt.name, banner.Severity, tt.severity)
}
}
}
func TestErrorBannerView(t *testing.T) {
banner := tui.NewErrorBanner(tui.SevError)
banner.Show("Error Title", "Error detail", "Hint 1", "Hint 2")
// Test that View returns non-empty string
output := banner.View(80)
if output == "" {
t.Error("View() returned empty string")
}
// Test that View returns empty string when not visible
banner.Hide()
output = banner.View(80)
if output != "" {
t.Error("View() should return empty string when not visible")
}
}
+92
View File
@@ -0,0 +1,92 @@
package tui_test
import (
"testing"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/tui"
)
// 5.1 WrapFooter short — single line
func TestWrapFooterShort(t *testing.T) {
result := tui.WrapFooter("short footer", 80)
if result != "short footer" {
t.Errorf("WrapFooter short = %q, want %q", result, "short footer")
}
}
// 5.2 WrapFooter long — multi-line
func TestWrapFooterLong(t *testing.T) {
// Footer longer than 30 chars should wrap
footer := "key1 key2 key3 key4 key5 key6 key7 key8 key9 key10"
result := tui.WrapFooter(footer, 30)
if result == footer {
t.Error("WrapFooter should wrap long footer")
}
}
// 5.3 WrapFooter empty
func TestWrapFooterEmpty(t *testing.T) {
result := tui.WrapFooter("", 80)
if result != "" {
t.Errorf("WrapFooter empty = %q, want %q", result, "")
}
}
// 5.4 ClampWidth over max
func TestClampWidthOver(t *testing.T) {
result := tui.ClampWidth(200, 80)
if result > 74 { // 80 - 6 (boxOverhead)
t.Errorf("ClampWidth(200, 80) = %d, should be <= 74", result)
}
}
// 5.5 ClampWidth under min
func TestClampWidthUnder(t *testing.T) {
result := tui.ClampWidth(5, 80)
if result < 20 {
t.Errorf("ClampWidth(5, 80) = %d, should be >= 20", result)
}
}
// 5.6 ClampWidth in range
func TestClampWidthInRange(t *testing.T) {
result := tui.ClampWidth(50, 80)
if result != 50 {
t.Errorf("ClampWidth(50, 80) = %d, want 50", result)
}
}
// 5.7 TruncateStr short
func TestTruncateStrShort(t *testing.T) {
result := tui.TruncateStr("hello", 20)
if result != "hello" {
t.Errorf("TruncateStr short = %q, want %q", result, "hello")
}
}
// 5.8 TruncateStr long
func TestTruncateStrLong(t *testing.T) {
result := tui.TruncateStr("this is a very long string that should be truncated", 10)
if result == "this is a very long string that should be truncated" {
t.Error("TruncateStr should truncate long string")
}
if len(result) > 12 { // 11 content + 1 ellipsis
t.Errorf("TruncateStr result too long: %d chars", len(result))
}
}
// 5.9 TruncateStr empty
func TestTruncateStrEmpty(t *testing.T) {
result := tui.TruncateStr("", 20)
if result != "" {
t.Errorf("TruncateStr empty = %q, want %q", result, "")
}
}
// 5.10 TruncateStr unicode
func TestTruncateStrUnicode(t *testing.T) {
result := tui.TruncateStr("こんにちは世界", 5)
if result == "こんにちは世界" {
t.Error("TruncateStr should truncate unicode string")
}
}
+71
View File
@@ -0,0 +1,71 @@
package tui_test
import (
"testing"
"git.tukangketik.id/swanadiva/hostkeeper/pkg/tui"
)
func TestGetTheme(t *testing.T) {
// Test getting existing themes
tests := []struct {
name string
expected string
}{
{"dark", "dark"},
{"light", "light"},
{"dracula", "dracula"},
}
for _, tt := range tests {
theme := tui.GetTheme(tt.name)
if theme.Name != tt.expected {
t.Errorf("GetTheme(%q) = %q, want %q", tt.name, theme.Name, tt.expected)
}
}
// Test getting non-existing theme defaults to dark
theme := tui.GetTheme("nonexistent")
if theme.Name != "dark" {
t.Errorf("GetTheme(nonexistent) = %q, want %q", theme.Name, "dark")
}
}
func TestSetTheme(t *testing.T) {
// Set theme to light
tui.SetTheme("light")
active := tui.GetActiveTheme()
if active.Name != "light" {
t.Errorf("After SetTheme(light), GetActiveTheme() = %q, want %q", active.Name, "light")
}
// Set theme back to dark
tui.SetTheme("dark")
active = tui.GetActiveTheme()
if active.Name != "dark" {
t.Errorf("After SetTheme(dark), GetActiveTheme() = %q, want %q", active.Name, "dark")
}
}
func TestGetActiveTheme(t *testing.T) {
// Default should be dark
active := tui.GetActiveTheme()
if active.Name != "dark" {
t.Errorf("GetActiveTheme() = %q, want %q", active.Name, "dark")
}
}
func TestThemeRegistry(t *testing.T) {
// Test that all themes are registered
expectedThemes := []string{"dark", "light", "dracula"}
for _, name := range expectedThemes {
if _, ok := tui.Themes[name]; !ok {
t.Errorf("Theme %q not found in Themes map", name)
}
}
// Test theme count
if len(tui.Themes) != 3 {
t.Errorf("Themes map has %d entries, want 3", len(tui.Themes))
}
}