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") } }