Files
ssh-netbox-wrapper/internal/cache/cache_test.go
T
Sebastian UnterschützandClaude Opus 5.5 a950a82552 feat: hostname resolver strategy and static hosts
Add the hostname resolver strategy. It connects by the NetBox name
instead of an IP, with resolver.hostname_domain appended to names
without a dot. Its position in the chain decides whether it is always
used or only as a fallback (e.g. [primary_ip, hostname]); names that
are not valid DNS hostnames fall through to the next strategy.

Add static hosts under hosts: for machines that are not in NetBox,
with address, user, port, tags, and their own SSH options. They are
checked before NetBox (by name or shortcut), never expire, survive
cache refresh and clear, and work without any NetBox configured. The
cache overlays them after every load and stores only their last use,
so they appear in completion, aliases, the TUI and its recent list.
SSH options merge global, static host, then rules; rules can match
kind: static. Manage them with netssh hosts list|add|remove and in
netssh configure, which can now also continue without NetBox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:46:13 +01:00

560 lines
15 KiB
Go

package cache
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestNew(t *testing.T) {
c := New("/tmp/test.json", 60)
if c == nil {
t.Fatal("New returned nil")
}
if c.ttl != 60*time.Second {
t.Errorf("ttl: got %v, want %v", c.ttl, 60*time.Second)
}
}
func TestLoad_MissingFile(t *testing.T) {
c := New("/nonexistent/path/cache.json", 60)
if err := c.Load(); err != nil {
t.Errorf("Load on missing file should not error, got: %v", err)
}
}
func TestLoad_InvalidJSON(t *testing.T) {
f := tempFile(t, []byte("not json"))
c := New(f, 60)
if err := c.Load(); err == nil {
t.Error("Load on invalid JSON should return an error")
}
}
func TestSaveAndLoad_Roundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
c := New(path, 3600)
c.Upsert(Entry{Name: "host-a", IP: "10.0.0.1", Kind: "device"})
c.Upsert(Entry{Name: "host-b", IP: "10.0.0.2", Kind: "vm", Tags: []string{"prod"}})
if err := c.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
c2 := New(path, 3600)
if err := c2.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
e, _ := c2.Get("host-a")
if e.IP != "10.0.0.1" {
t.Errorf("host-a IP: got %q, want %q", e.IP, "10.0.0.1")
}
e2, _ := c2.Get("host-b")
if len(e2.Tags) != 1 || e2.Tags[0] != "prod" {
t.Errorf("host-b tags: got %v, want [prod]", e2.Tags)
}
}
func TestSave_CreatesDirectory(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sub", "dir", "cache.json")
c := New(path, 60)
c.Upsert(Entry{Name: "x", IP: "1.2.3.4", Kind: "device"})
if err := c.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("cache file not created: %v", err)
}
}
func TestUpsert_SetsTimestamp(t *testing.T) {
c := New("", 60)
before := time.Now()
c.Upsert(Entry{Name: "h", IP: "1.1.1.1", Kind: "device"})
e, _ := c.Get("h")
if e.CachedAt.Before(before) {
t.Error("CachedAt should be set to current time on Upsert")
}
}
func TestUpsert_Overwrites(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
c.Upsert(Entry{Name: "host", IP: "10.0.0.2", Kind: "device"})
e, _ := c.Get("host")
if e.IP != "10.0.0.2" {
t.Errorf("Upsert should overwrite: got %q, want %q", e.IP, "10.0.0.2")
}
}
func TestSearch_PrefixMatch(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "app-server-01", IP: "10.0.0.1", Kind: "device"})
c.Upsert(Entry{Name: "app-server-02", IP: "10.0.0.2", Kind: "vm"})
c.Upsert(Entry{Name: "db-server-01", IP: "10.0.0.3", Kind: "device"})
results := c.Search("app")
if len(results) != 2 {
t.Errorf("Search(app): got %d results, want 2", len(results))
}
}
func TestSearch_CaseInsensitive(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "App-Server", IP: "10.0.0.1", Kind: "device"})
if len(c.Search("app")) != 1 {
t.Error("Search should be case-insensitive")
}
if len(c.Search("APP")) != 1 {
t.Error("Search should be case-insensitive for uppercase")
}
}
func TestSearch_EmptyPrefix(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "vm"})
if len(c.Search("")) != 2 {
t.Error("Search('') should return all entries")
}
}
func TestSearch_NoMatch(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "host", IP: "1.1.1.1", Kind: "device"})
if len(c.Search("xyz")) != 0 {
t.Error("Search should return empty slice when no match")
}
}
func TestGet_Fresh(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
e, fresh := c.Get("host")
if !fresh {
t.Error("entry just inserted should be fresh")
}
if e.IP != "10.0.0.1" {
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.1")
}
}
func TestGet_Expired(t *testing.T) {
c := New("", 1) // 1 second TTL
e := Entry{Name: "host", IP: "10.0.0.1", Kind: "device", CachedAt: time.Now().Add(-2 * time.Second)}
c.mu.Lock()
c.entries["host"] = e
c.mu.Unlock()
_, fresh := c.Get("host")
if fresh {
t.Error("entry older than TTL should not be fresh")
}
}
func TestGet_ZeroTTL_AlwaysStale(t *testing.T) {
c := New("", 0) // TTL=0 means never fresh for connect mode
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
_, fresh := c.Get("host")
if fresh {
t.Error("TTL=0 should always return fresh=false")
}
}
func TestGet_Missing(t *testing.T) {
c := New("", 60)
_, fresh := c.Get("nonexistent")
if fresh {
t.Error("missing entry should not be fresh")
}
}
func TestClear(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "vm"})
c.Clear()
if len(c.All()) != 0 {
t.Error("Clear should remove all entries")
}
}
func TestAll(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "vm"})
all := c.All()
if len(all) != 2 {
t.Errorf("All: got %d entries, want 2", len(all))
}
}
func TestSave_ProducesValidJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
c := New(path, 60)
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device", Tags: []string{"mgmt"}})
if err := c.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
data, _ := os.ReadFile(path)
var df diskFormat
if err := json.Unmarshal(data, &df); err != nil {
t.Fatalf("saved file is not valid JSON: %v", err)
}
if len(df.Entries) != 1 {
t.Errorf("expected 1 entry in JSON, got %d", len(df.Entries))
}
}
func TestMarkUsed_SetsLastUsed(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
before := time.Now()
c.MarkUsed("host")
e, _ := c.Get("host")
if e.LastUsed.Before(before) {
t.Error("LastUsed should be set to current time by MarkUsed")
}
}
func TestMarkUsed_NoopForMissingEntry(t *testing.T) {
c := New("", 60)
c.MarkUsed("nonexistent") // should not panic
}
func TestRecentlyUsed_ReturnsTopN(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "device"})
c.Upsert(Entry{Name: "c", IP: "3.3.3.3", Kind: "device"})
c.MarkUsed("c")
time.Sleep(time.Millisecond)
c.MarkUsed("a")
results := c.RecentlyUsed(2)
if len(results) != 2 {
t.Fatalf("RecentlyUsed(2): got %d results, want 2", len(results))
}
if results[0].Name != "a" {
t.Errorf("first result: got %q, want %q", results[0].Name, "a")
}
if results[1].Name != "c" {
t.Errorf("second result: got %q, want %q", results[1].Name, "c")
}
}
func TestRecentlyUsed_ExcludesNeverUsed(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "used", IP: "1.1.1.1", Kind: "device"})
c.Upsert(Entry{Name: "unused", IP: "2.2.2.2", Kind: "device"})
c.MarkUsed("used")
results := c.RecentlyUsed(10)
if len(results) != 1 || results[0].Name != "used" {
t.Errorf("RecentlyUsed should exclude entries with zero LastUsed, got %v", results)
}
}
func TestRecentlyUsed_EmptyCache(t *testing.T) {
c := New("", 60)
if results := c.RecentlyUsed(10); len(results) != 0 {
t.Errorf("RecentlyUsed on empty cache: got %d, want 0", len(results))
}
}
func TestMarkUsed_RoundtripViaSave(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
c := New(path, 3600)
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
c.MarkUsed("host")
if err := c.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
c2 := New(path, 3600)
if err := c2.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
results := c2.RecentlyUsed(1)
if len(results) != 1 || results[0].Name != "host" {
t.Errorf("LastUsed not persisted: %v", results)
}
}
// --- GetByShortcut tests ---
func TestGetByShortcut_MatchFresh(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device"})
normalize := func(s string) string {
// strip .example.com
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
s = s[:len(s)-len(".example.com")]
}
return s
}
e, found, fresh, _ := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("expected entry to be found")
}
if !fresh {
t.Error("entry just inserted should be fresh")
}
if e.IP != "10.0.0.1" {
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.1")
}
}
func TestGetByShortcut_MatchStale(t *testing.T) {
c := New("", 1) // 1 second TTL
stale := Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device", CachedAt: time.Now().Add(-2 * time.Second)}
c.mu.Lock()
c.entries["web01.example.com"] = stale
c.mu.Unlock()
normalize := func(s string) string {
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
s = s[:len(s)-len(".example.com")]
}
return s
}
_, found, fresh, _ := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("expected entry to be found even when stale")
}
if fresh {
t.Error("entry older than TTL should not be fresh")
}
}
func TestGetByShortcut_NotFound(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "db01.example.com", IP: "10.0.0.2", Kind: "device"})
_, found, _, _ := c.GetByShortcut("web01", func(s string) string { return s })
if found {
t.Error("should not find an entry that does not match the shortcut")
}
}
func TestGetByShortcut_EmptyCache(t *testing.T) {
c := New("", 3600)
_, found, _, _ := c.GetByShortcut("web01", func(s string) string { return s })
if found {
t.Error("empty cache should return found=false")
}
}
func TestGetByShortcut_MultiDomain(t *testing.T) {
c := New("", 3600)
// Entry uses second domain (.example.de)
c.Upsert(Entry{Name: "web01.example.de", IP: "10.0.0.5", Kind: "vm"})
normalize := func(s string) string {
for _, suffix := range []string{".example.com", ".example.de"} {
if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix {
return s[:len(s)-len(suffix)]
}
}
return s
}
e, found, _, _ := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("should match entry with second configured domain")
}
if e.IP != "10.0.0.5" {
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.5")
}
}
// --- NeedsRefresh / SetRefreshed tests ---
// These tests require NeedsRefresh(time.Duration) bool and SetRefreshed() error
// to be implemented on *Cache. The refreshStamp field (path to the timestamp file)
// must be set before calling these methods.
func TestNeedsRefresh_NeverRefreshed(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if !c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return true when last_refresh file does not exist")
}
}
func TestNeedsRefresh_JustRefreshed(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if err := c.SetRefreshed(); err != nil {
t.Fatalf("SetRefreshed: %v", err)
}
if c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return false immediately after SetRefreshed")
}
}
func TestNeedsRefresh_Stale(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
// Write a timestamp 25 hours ago
oldTime := time.Now().Add(-25 * time.Hour).Format(time.RFC3339)
if err := os.WriteFile(stampPath, []byte(oldTime), 0o644); err != nil {
t.Fatalf("writing stamp: %v", err)
}
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if !c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return true when last_refresh is older than duration")
}
}
func TestSetRefreshed_Roundtrip(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if err := c.SetRefreshed(); err != nil {
t.Fatalf("SetRefreshed: %v", err)
}
// Just refreshed: 24h window → not stale
if c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh(24h) should be false right after SetRefreshed")
}
// Zero duration: everything is stale
if !c.NeedsRefresh(0) {
t.Error("NeedsRefresh(0) should always return true")
}
}
// tempFile writes content to a temp file and returns its path.
func tempFile(t *testing.T, content []byte) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "cache-*.json")
if err != nil {
t.Fatal(err)
}
if _, err := f.Write(content); err != nil {
t.Fatal(err)
}
f.Close()
return f.Name()
}
func TestUpsertPreservesUsageHistory(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "web01", IP: "10.0.0.1"})
c.MarkUsed("web01")
before, _ := c.Get("web01")
c.Upsert(Entry{Name: "web01", IP: "10.0.0.2"})
after, _ := c.Get("web01")
if after.LastUsed != before.LastUsed || after.IP != "10.0.0.2" {
t.Fatalf("history lost: %+v", after)
}
}
func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "office/web01", IP: "10.0.0.1"})
c.Upsert(Entry{Name: "lab/web01", IP: "10.0.0.2"})
c.Upsert(Entry{Name: "lab/db01", IP: "10.0.0.3"})
for query, want := range map[string]int{"web": 2, "office/web": 1, "lab/": 2, "WEB": 2} {
if got := len(c.Search(query)); got != want {
t.Errorf("Search(%q)=%d, want %d", query, got, want)
}
}
}
func TestStaticHosts(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts.json")
hosts := []config.StaticHost{{Name: "router", Address: "192.168.1.1", Tags: []string{"home"}}, {Name: "nas", Address: "nas.lan"}}
c := New(path, 1).WithStatic(hosts)
c.Upsert(Entry{Name: "web01", IP: "10.0.0.1", Kind: "device"})
if err := c.Load(); err != nil { // no file yet; static hosts stay
t.Fatal(err)
}
if e, fresh := c.Get("router"); !fresh || e.IP != "192.168.1.1" || e.Kind != config.StaticKind || !e.Static {
t.Fatalf("static get: %+v %v", e, fresh)
}
if e, found, err := c.GetStatic("ROUTER", nil); !found || err != nil || e.Name != "router" {
t.Fatalf("by name: %+v %v %v", e, found, err)
}
normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(s), ".lan") }
if e, found, _ := c.GetStatic("nas.lan", normalize); !found || e.Name != "nas" {
t.Fatalf("by shortcut: %+v", e)
}
if _, found, _ := c.GetStatic("web01", normalize); found {
t.Fatal("NetBox entry returned as static")
}
// Usage of a static host persists; the host itself stays after Save and Clear.
c.MarkUsed("router")
if err := c.Save(); err != nil {
t.Fatal(err)
}
if _, fresh := c.Get("nas"); !fresh {
t.Fatal("static host lost after Save")
}
reloaded := New(path, 1).WithStatic(hosts)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
if recent := reloaded.RecentlyUsed(5); len(recent) != 1 || recent[0].Name != "router" {
t.Fatalf("recent: %+v", recent)
}
reloaded.Clear()
if _, fresh := reloaded.Get("router"); !fresh {
t.Fatal("Clear removed a static host")
}
// A host removed from the config disappears, also from the file.
removed := New(path, 1).WithStatic(hosts[1:])
if err := removed.Load(); err != nil {
t.Fatal(err)
}
if e, _ := removed.Get("router"); e.Name != "" {
t.Fatalf("removed static host still present: %+v", e)
}
if err := removed.Save(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "192.168.1.1") {
t.Fatalf("removed static host still on disk:\n%s", data)
}
}