test: Phase 3 comprehensive test coverage — 105 tests, zero race conditions

- Add test/crypto/ (19 tests): AES-256-GCM encrypt/decrypt, PBKDF2, IsEncrypted, HashPassword
- Add test/knownhosts/ (14 tests): TOFU verify, MITM detection, CRUD, persistence
- Add test/storage/ (20 tests): KeyPair/Snippet CRUD, encryption, MergeStrategy
- Add test/config/ (9 tests): config lifecycle, path getters
- Add test/tui/ (10 tests): WrapFooter, ClampWidth, TruncateStr
- Fix knownhosts deadlock: Add/Remove use saveInternal()
- Export responsive.go functions for testing
- Add docs/TEST_PLAN.md with full scenario documentation

Coverage: crypto 0%→100%, knownhosts 0%→100%, storage 40%→90%, config 22%→80%, tui 40%→70%
This commit is contained in:
swanadiva
2026-06-29 12:05:02 +07:00
parent 408681c8e4
commit 4a8b4a2bc4
10 changed files with 1439 additions and 7 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'")
}
}
+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 }
+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")
}
}
+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")
}
}