7c902cab3a
Release / release (push) Successful in 50s
- **Shortcuts**: Add hostname normalization with domain stripping and hyphen folding. Include alias generation for cached hosts. - **Shell Hook**: Automate 24h cache refresh trigger with shell startup hook. Add install/uninstall commands for bash, zsh, and fish. - **Wizard**: Extend setup wizard to configure shortcuts (domains, hyphen stripping) and default SSH port. - **Cache**: Add `GetByShortcut` for resolving hosts via normalized shortcuts. Implement `NeedsRefresh` / `SetRefreshed` logic for refresh timestamps. - **Tests**: Comprehensive unit tests for shortcuts, hook installation, cache refresh, and alias generation. - **Docs**: Update README with shortcuts, shell hook, and default SSH port configuration.
474 lines
12 KiB
Go
474 lines
12 KiB
Go
package cache
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
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()
|
|
}
|