847989df75
- 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
393 lines
9.9 KiB
Go
393 lines
9.9 KiB
Go
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")
|
|
}
|
|
}
|