Files
nexus/internal/storage/storage_test.go
T

696 lines
20 KiB
Go

package storage
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
"github.com/andydixon/nexus/internal/models"
"github.com/google/uuid"
)
// setupTestStore creates an isolated Store backed by a temporary SQLite database.
func setupTestStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
// makeSSHProfile returns a minimal valid SSH profile for testing.
func makeSSHProfile(name, host string) *models.Profile {
p := models.NewProfile(models.ProtocolSSH)
p.DisplayName = name
p.Host = host
p.Username = "testuser"
return &p
}
func makeIRCProfile(name, host string) *models.Profile {
p := models.NewProfile(models.ProtocolIRC)
p.DisplayName = name
p.Host = host
p.Username = "nexus"
p.IRC.Nick = "nexus"
return &p
}
// ---------------------------------------------------------------------------
// Profile CRUD
// ---------------------------------------------------------------------------
func TestCreateAndGetProfile(t *testing.T) {
store := setupTestStore(t)
p := makeSSHProfile("My Server", "10.0.0.1")
if err := store.CreateProfile(p); err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
got, err := store.GetProfile(p.ID)
if err != nil {
t.Fatalf("GetProfile() error = %v", err)
}
if got.DisplayName != "My Server" {
t.Errorf("DisplayName = %q, want %q", got.DisplayName, "My Server")
}
if got.Host != "10.0.0.1" {
t.Errorf("Host = %q, want %q", got.Host, "10.0.0.1")
}
if got.Protocol != models.ProtocolSSH {
t.Errorf("Protocol = %q, want %q", got.Protocol, models.ProtocolSSH)
}
}
func TestUpdateProfile(t *testing.T) {
store := setupTestStore(t)
p := makeSSHProfile("Original", "host.example.com")
_ = store.CreateProfile(p)
p.DisplayName = "Updated"
p.Host = "new-host.example.com"
p.UpdatedAt = time.Now()
if err := store.UpdateProfile(p); err != nil {
t.Fatalf("UpdateProfile() error = %v", err)
}
got, err := store.GetProfile(p.ID)
if err != nil {
t.Fatalf("GetProfile() error = %v", err)
}
if got.DisplayName != "Updated" {
t.Errorf("DisplayName = %q, want %q", got.DisplayName, "Updated")
}
if got.Host != "new-host.example.com" {
t.Errorf("Host = %q, want %q", got.Host, "new-host.example.com")
}
}
func TestDeleteProfile(t *testing.T) {
store := setupTestStore(t)
p := makeSSHProfile("ToDelete", "host.local")
_ = store.CreateProfile(p)
if err := store.DeleteProfile(p.ID); err != nil {
t.Fatalf("DeleteProfile() error = %v", err)
}
if _, err := store.GetProfile(p.ID); err == nil {
t.Error("expected error when getting deleted profile")
}
}
func TestDeleteProfileNotFound(t *testing.T) {
store := setupTestStore(t)
if err := store.DeleteProfile("nonexistent"); err == nil {
t.Error("expected error when deleting non-existent profile")
}
}
func TestIRCLastRoomsRoundTripReplaceAndDelete(t *testing.T) {
store := setupTestStore(t)
p := makeIRCProfile("IRC Network", "irc.example.com")
if err := store.CreateProfile(p); err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
if err := store.SaveIRCLastRooms(p.ID, []string{" #Go-Nuts ", "#nexus", "#go-nuts", ""}); err != nil {
t.Fatalf("SaveIRCLastRooms() error = %v", err)
}
rooms, err := store.GetIRCLastRooms(p.ID)
if err != nil {
t.Fatalf("GetIRCLastRooms() error = %v", err)
}
if got, want := strings.Join(rooms, ","), "#Go-Nuts,#nexus"; got != want {
t.Fatalf("GetIRCLastRooms() = %q, want %q", got, want)
}
if err := store.SaveIRCLastRooms(p.ID, []string{"#new-room"}); err != nil {
t.Fatalf("SaveIRCLastRooms(replace) error = %v", err)
}
rooms, err = store.GetIRCLastRooms(p.ID)
if err != nil {
t.Fatalf("GetIRCLastRooms(replaced) error = %v", err)
}
if got, want := strings.Join(rooms, ","), "#new-room"; got != want {
t.Fatalf("GetIRCLastRooms(replaced) = %q, want %q", got, want)
}
if err := store.DeleteProfile(p.ID); err != nil {
t.Fatalf("DeleteProfile() error = %v", err)
}
rooms, err = store.GetIRCLastRooms(p.ID)
if err != nil {
t.Fatalf("GetIRCLastRooms(after delete) error = %v", err)
}
if len(rooms) != 0 {
t.Fatalf("GetIRCLastRooms(after delete) = %v, want empty", rooms)
}
}
func TestSaveIRCLastRoomsRejectsEmptyProfileID(t *testing.T) {
store := setupTestStore(t)
if err := store.SaveIRCLastRooms(" ", []string{"#room"}); err == nil {
t.Fatal("SaveIRCLastRooms() accepted an empty profile ID")
}
}
// ---------------------------------------------------------------------------
// List and search
// ---------------------------------------------------------------------------
func TestListProfiles(t *testing.T) {
store := setupTestStore(t)
_ = store.CreateProfile(makeSSHProfile("Alpha", "a.example.com"))
_ = store.CreateProfile(makeSSHProfile("Beta", "b.example.com"))
profiles, err := store.ListProfiles()
if err != nil {
t.Fatalf("ListProfiles() error = %v", err)
}
if len(profiles) != 2 {
t.Errorf("ListProfiles() returned %d profiles, want 2", len(profiles))
}
}
func TestSearchProfiles(t *testing.T) {
store := setupTestStore(t)
_ = store.CreateProfile(makeSSHProfile("Production Web", "web.prod.com"))
_ = store.CreateProfile(makeSSHProfile("Staging DB", "db.staging.com"))
results, err := store.SearchProfiles("prod")
if err != nil {
t.Fatalf("SearchProfiles() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("SearchProfiles(\"prod\") returned %d results, want 1", len(results))
}
if results[0].DisplayName != "Production Web" {
t.Errorf("DisplayName = %q, want %q", results[0].DisplayName, "Production Web")
}
}
func TestSearchProfilesNoResults(t *testing.T) {
store := setupTestStore(t)
_ = store.CreateProfile(makeSSHProfile("Foo", "foo.local"))
results, err := store.SearchProfiles("zzz-no-match")
if err != nil {
t.Fatalf("SearchProfiles() error = %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
}
func TestListProfilesByProtocol(t *testing.T) {
store := setupTestStore(t)
ssh := makeSSHProfile("SSH Server", "ssh.local")
_ = store.CreateProfile(ssh)
telnet := models.NewProfile(models.ProtocolTelnet)
telnet.DisplayName = "Telnet Switch"
telnet.Host = "switch.local"
_ = store.CreateProfile(&telnet)
sshResults, err := store.ListProfilesByProtocol("ssh")
if err != nil {
t.Fatalf("ListProfilesByProtocol(ssh) error = %v", err)
}
if len(sshResults) != 1 {
t.Errorf("expected 1 SSH profile, got %d", len(sshResults))
}
telnetResults, err := store.ListProfilesByProtocol("telnet")
if err != nil {
t.Fatalf("ListProfilesByProtocol(telnet) error = %v", err)
}
if len(telnetResults) != 1 {
t.Errorf("expected 1 Telnet profile, got %d", len(telnetResults))
}
}
// ---------------------------------------------------------------------------
// Favourites
// ---------------------------------------------------------------------------
func TestFavourites(t *testing.T) {
store := setupTestStore(t)
p := makeSSHProfile("Fav Server", "fav.local")
_ = store.CreateProfile(p)
// Initially no favourites.
favs, err := store.ListFavourites()
if err != nil {
t.Fatalf("ListFavourites() error = %v", err)
}
if len(favs) != 0 {
t.Fatalf("expected 0 favourites, got %d", len(favs))
}
// Mark as favourite.
if err := store.SetFavourite(p.ID, true); err != nil {
t.Fatalf("SetFavourite(true) error = %v", err)
}
favs, _ = store.ListFavourites()
if len(favs) != 1 {
t.Fatalf("expected 1 favourite, got %d", len(favs))
}
// Unmark.
if err := store.SetFavourite(p.ID, false); err != nil {
t.Fatalf("SetFavourite(false) error = %v", err)
}
favs, _ = store.ListFavourites()
if len(favs) != 0 {
t.Errorf("expected 0 favourites after unmark, got %d", len(favs))
}
}
// ---------------------------------------------------------------------------
// Known hosts
// ---------------------------------------------------------------------------
func TestKnownHostCRUD(t *testing.T) {
store := setupTestStore(t)
now := time.Now()
kh := &models.KnownHost{
ID: uuid.New().String(),
Host: "server.example.com",
Port: 22,
KeyType: "ssh-ed25519",
Fingerprint: "SHA256:abcdef1234567890",
PublicKey: "AAAAC3NzaC1lZDI1NTE5...",
FirstSeen: now,
LastSeen: now,
Trusted: true,
}
// Save and retrieve.
if err := store.SaveKnownHost(kh); err != nil {
t.Fatalf("SaveKnownHost() error = %v", err)
}
got, err := store.GetKnownHost("server.example.com", 22)
if err != nil {
t.Fatalf("GetKnownHost() error = %v", err)
}
if got == nil {
t.Fatal("GetKnownHost() returned nil")
}
if got.Fingerprint != kh.Fingerprint {
t.Errorf("Fingerprint = %q, want %q", got.Fingerprint, kh.Fingerprint)
}
// List should contain it.
hosts, err := store.ListKnownHosts()
if err != nil {
t.Fatalf("ListKnownHosts() error = %v", err)
}
if len(hosts) != 1 {
t.Fatalf("expected 1 known host, got %d", len(hosts))
}
// Delete.
if err := store.DeleteKnownHost(kh.ID); err != nil {
t.Fatalf("DeleteKnownHost() error = %v", err)
}
hosts, _ = store.ListKnownHosts()
if len(hosts) != 0 {
t.Errorf("expected 0 known hosts after delete, got %d", len(hosts))
}
}
func TestGetKnownHostNotFound(t *testing.T) {
store := setupTestStore(t)
got, err := store.GetKnownHost("no-such-host", 22)
if err != nil {
t.Fatalf("GetKnownHost() error = %v", err)
}
if got != nil {
t.Error("expected nil for unknown host")
}
}
// ---------------------------------------------------------------------------
// Preferences
// ---------------------------------------------------------------------------
func TestPreferencesDefaults(t *testing.T) {
store := setupTestStore(t)
// No preferences stored yet — should return nil.
prefs, err := store.GetPreferences()
if err != nil {
t.Fatalf("GetPreferences() error = %v", err)
}
if prefs != nil {
t.Error("expected nil preferences on empty database")
}
}
func TestSavePreferencesDoesNotError(t *testing.T) {
store := setupTestStore(t)
prefs := models.DefaultAppPreferences()
prefs.ConfirmOnClose = true
prefs.DefaultFontSize = 16
prefs.Theme = "custom-dark"
prefs.LeviathanEnabled = true
if err := store.SavePreferences(&prefs); err != nil {
t.Fatalf("SavePreferences() error = %v", err)
}
got, err := store.GetPreferences()
if err != nil {
t.Fatalf("GetPreferences() error = %v", err)
}
if got == nil {
t.Fatal("GetPreferences() returned nil after save")
}
if got.ConfirmOnClose != true {
t.Errorf("ConfirmOnClose = %v, want true", got.ConfirmOnClose)
}
if got.DefaultFontSize != 16 {
t.Errorf("DefaultFontSize = %d, want 16", got.DefaultFontSize)
}
if got.Theme != "custom-dark" {
t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark")
}
if !got.LeviathanEnabled {
t.Error("LeviathanEnabled = false, want true")
}
}
// ---------------------------------------------------------------------------
// Audit log
// ---------------------------------------------------------------------------
func TestAuditEntries(t *testing.T) {
store := setupTestStore(t)
entry := &models.AuditEntry{
ID: uuid.New().String(),
Timestamp: time.Now(),
Action: "connect",
ProfileID: "profile-1",
SessionID: "session-1",
Details: "Connected via SSH to 10.0.0.1",
}
if err := store.AddAuditEntry(entry); err != nil {
t.Fatalf("AddAuditEntry() error = %v", err)
}
entries, err := store.ListAuditEntries(10)
if err != nil {
t.Fatalf("ListAuditEntries() error = %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 audit entry, got %d", len(entries))
}
if entries[0].Action != "connect" {
t.Errorf("Action = %q, want %q", entries[0].Action, "connect")
}
if entries[0].Details != "Connected via SSH to 10.0.0.1" {
t.Errorf("Details = %q, want %q", entries[0].Details, "Connected via SSH to 10.0.0.1")
}
}
func TestAuditEntriesLimit(t *testing.T) {
store := setupTestStore(t)
for i := 0; i < 5; i++ {
_ = store.AddAuditEntry(&models.AuditEntry{
ID: uuid.New().String(),
Timestamp: time.Now(),
Action: "test",
Details: "entry",
})
}
entries, err := store.ListAuditEntries(3)
if err != nil {
t.Fatalf("ListAuditEntries() error = %v", err)
}
if len(entries) != 3 {
t.Errorf("expected 3 audit entries with limit, got %d", len(entries))
}
}
// ---------------------------------------------------------------------------
// UpdateLastConnected
// ---------------------------------------------------------------------------
func TestUpdateLastConnected(t *testing.T) {
store := setupTestStore(t)
p := makeSSHProfile("Connectable", "conn.local")
_ = store.CreateProfile(p)
// Initially last_connected should be nil.
got, err := store.GetProfile(p.ID)
if err != nil {
t.Fatalf("GetProfile() error = %v", err)
}
if got.LastConnected != nil {
t.Error("expected LastConnected to be nil initially")
}
// Update last connected.
if err := store.UpdateLastConnected(p.ID); err != nil {
t.Fatalf("UpdateLastConnected() error = %v", err)
}
got, err = store.GetProfile(p.ID)
if err != nil {
t.Fatalf("GetProfile() after update error = %v", err)
}
if got.LastConnected == nil {
t.Fatal("expected LastConnected to be set after update")
}
// Should be within the last few seconds.
elapsed := time.Since(*got.LastConnected)
if elapsed > 5*time.Second {
t.Errorf("LastConnected is too old: %v ago", elapsed)
}
}
func TestUpdateLastConnectedNotFound(t *testing.T) {
store := setupTestStore(t)
if err := store.UpdateLastConnected("nonexistent"); err == nil {
t.Error("expected error when updating non-existent profile")
}
}
// ---------------------------------------------------------------------------
// Export / Import
// ---------------------------------------------------------------------------
func TestExportProfiles(t *testing.T) {
store := setupTestStore(t)
_ = store.CreateProfile(makeSSHProfile("Export A", "a.example.com"))
_ = store.CreateProfile(makeSSHProfile("Export B", "b.example.com"))
data, err := store.ExportProfiles(false)
if err != nil {
t.Fatalf("ExportProfiles() error = %v", err)
}
if len(data) == 0 {
t.Fatal("ExportProfiles() returned empty data")
}
// The exported JSON should be parseable as a slice of profiles.
var profiles []*models.Profile
if err := json.Unmarshal(data, &profiles); err != nil {
t.Fatalf("failed to unmarshal exported data: %v", err)
}
if len(profiles) != 2 {
t.Errorf("expected 2 exported profiles, got %d", len(profiles))
}
}
func TestExportProfilesRedactsInlineSSHSecrets(t *testing.T) {
store := setupTestStore(t)
profile := makeSSHProfile("Export Secret", "secret.example.com")
profile.SSH.ProxyPassword = "proxy-secret"
profile.SSH.TOTPSecret = "totp-secret"
if err := store.CreateProfile(profile); err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
data, err := store.ExportProfiles(false)
if err != nil {
t.Fatalf("ExportProfiles(false) error = %v", err)
}
if strings.Contains(string(data), "proxy-secret") || strings.Contains(string(data), "totp-secret") {
t.Fatalf("ExportProfiles(false) leaked inline SSH secrets: %s", string(data))
}
data, err = store.ExportProfiles(true)
if err != nil {
t.Fatalf("ExportProfiles(true) error = %v", err)
}
if !strings.Contains(string(data), "proxy-secret") || !strings.Contains(string(data), "totp-secret") {
t.Fatalf("ExportProfiles(true) did not include inline SSH secrets: %s", string(data))
}
}
func TestImportProfiles(t *testing.T) {
store := setupTestStore(t)
p := makeSSHProfile("Import Me", "import.local")
_ = store.CreateProfile(p)
// Export from source store.
data, err := store.ExportProfiles(false)
if err != nil {
t.Fatalf("ExportProfiles() error = %v", err)
}
// Import into a fresh store.
store2 := setupTestStore(t)
n, err := store2.ImportProfiles(data, "skip")
if err != nil {
t.Fatalf("ImportProfiles() error = %v", err)
}
if n != 1 {
t.Errorf("expected 1 imported profile, got %d", n)
}
profiles, _ := store2.ListProfiles()
if len(profiles) != 1 {
t.Fatalf("expected 1 profile in target store, got %d", len(profiles))
}
if profiles[0].DisplayName != "Import Me" {
t.Errorf("DisplayName = %q, want %q", profiles[0].DisplayName, "Import Me")
}
}
func TestImportDuplicateHandling(t *testing.T) {
store := setupTestStore(t)
p := makeSSHProfile("Original", "orig.local")
_ = store.CreateProfile(p)
data, err := store.ExportProfiles(false)
if err != nil {
t.Fatalf("ExportProfiles() error = %v", err)
}
// "skip" strategy — importing same profile again should import 0.
n, err := store.ImportProfiles(data, "skip")
if err != nil {
t.Fatalf("ImportProfiles(skip) error = %v", err)
}
if n != 0 {
t.Errorf("skip strategy: expected 0 imports, got %d", n)
}
// "overwrite" strategy — should overwrite the existing profile.
n, err = store.ImportProfiles(data, "overwrite")
if err != nil {
t.Fatalf("ImportProfiles(overwrite) error = %v", err)
}
if n != 1 {
t.Errorf("overwrite strategy: expected 1 import, got %d", n)
}
// Total profiles should still be 1.
all, _ := store.ListProfiles()
if len(all) != 1 {
t.Errorf("after overwrite: expected 1 profile, got %d", len(all))
}
// "duplicate" strategy — should create a new entry with a fresh ID.
n, err = store.ImportProfiles(data, "duplicate")
if err != nil {
t.Fatalf("ImportProfiles(duplicate) error = %v", err)
}
if n != 1 {
t.Errorf("duplicate strategy: expected 1 import, got %d", n)
}
all, _ = store.ListProfiles()
if len(all) != 2 {
t.Errorf("after duplicate: expected 2 profiles, got %d", len(all))
}
}
func TestImportInvalidStrategy(t *testing.T) {
store := setupTestStore(t)
data := []byte(`[]`)
_, err := store.ImportProfiles(data, "badstrategy")
if err == nil {
t.Error("expected error for invalid import strategy")
}
}
func TestImportInvalidJSON(t *testing.T) {
store := setupTestStore(t)
_, err := store.ImportProfiles([]byte("not json"), "skip")
if err == nil {
t.Error("expected error for invalid JSON import data")
}
}
func TestImportRejectsNullProfileEntry(t *testing.T) {
store := setupTestStore(t)
_, err := store.ImportProfiles([]byte(`[null]`), "skip")
if err == nil {
t.Fatal("expected error for null profile entry")
}
if !strings.Contains(err.Error(), "entry 0 is null") {
t.Fatalf("error = %q, want null entry detail", err.Error())
}
}
// ---------------------------------------------------------------------------
// Migration idempotency
// ---------------------------------------------------------------------------
func TestMigrateIdempotent(t *testing.T) {
// NewStore already runs migrate() internally. Creating a second Store
// against the same database verifies that migrations are idempotent.
dir := t.TempDir()
dbPath := filepath.Join(dir, "migrate-test.db")
s1, err := NewStore(dbPath)
if err != nil {
t.Fatalf("first NewStore() error = %v", err)
}
s1.Close()
s2, err := NewStore(dbPath)
if err != nil {
t.Fatalf("second NewStore() error = %v (migration not idempotent?)", err)
}
s2.Close()
}
func TestMigrateCreatesTablesForCRUD(t *testing.T) {
// Verify that a fresh store can perform basic CRUD — proving that
// migration created the required tables.
store := setupTestStore(t)
p := makeSSHProfile("Migration Test", "mig.local")
if err := store.CreateProfile(p); err != nil {
t.Fatalf("CreateProfile after migration failed: %v", err)
}
if _, err := store.GetProfile(p.ID); err != nil {
t.Fatalf("GetProfile after migration failed: %v", err)
}
if _, err := store.ListKnownHosts(); err != nil {
t.Fatalf("ListKnownHosts after migration failed: %v", err)
}
if _, err := store.ListAuditEntries(10); err != nil {
t.Fatalf("ListAuditEntries after migration failed: %v", err)
}
if _, err := store.GetPreferences(); err != nil {
t.Fatalf("GetPreferences after migration failed: %v", err)
}
}