8af44dfcd2
A fast, read-only ~/.ssh/config picker that exec's the system ssh client. Includes: static high-contrast picker with recency/frequency/name sort and tag grouping, `dial keygen` (native ed25519/RSA + optional config entry), and an optional offline YubiKey launch gate with one-time recovery codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
package picker
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
|
|
"gitea.apointless.space/bsncubed/dial/internal/store"
|
|
)
|
|
|
|
func sampleModel() *model {
|
|
hosts := []sshconf.Host{
|
|
{Alias: "web1", HostName: "10.0.0.5", User: "deploy", Port: "2222", ProxyJump: "bastion", Tags: []string{"prod", "riedel"}},
|
|
{Alias: "db-primary", HostName: "db.internal", User: "postgres", Port: "22", Tags: []string{"prod"}},
|
|
{Alias: "bastion", HostName: "bastion.example.com", User: "admin"},
|
|
{Alias: "staging-web", HostName: "10.1.0.9", User: "deploy", Tags: []string{"staging"}},
|
|
}
|
|
data := store.DefaultData()
|
|
data.Stats["web1"] = &store.HostStat{Count: 9, LastUsed: time.Now()}
|
|
data.Stats["bastion"] = &store.HostStat{Count: 3, LastUsed: time.Now().Add(-time.Hour)}
|
|
data.Favorites = []string{"bastion"}
|
|
|
|
settings := store.DefaultSettings()
|
|
|
|
m := newModelForTest(hosts, data, settings)
|
|
m.width, m.height = 90, 30
|
|
m.ensureVisible()
|
|
return m
|
|
}
|
|
|
|
// newModelForTest mirrors the setup Run does, without starting a tea program.
|
|
func newModelForTest(hosts []sshconf.Host, data store.Data, settings store.Settings) *model {
|
|
res, _ := buildModel(hosts, data, settings)
|
|
return res
|
|
}
|
|
|
|
func TestRenderSnapshot(t *testing.T) {
|
|
m := sampleModel()
|
|
out := m.View()
|
|
if !strings.Contains(out, "dial") {
|
|
t.Error("header missing")
|
|
}
|
|
if !strings.Contains(out, "web1") || !strings.Contains(out, "bastion") {
|
|
t.Error("hosts missing from render")
|
|
}
|
|
// bastion is a favourite, so it should sort to the top of a recent-sort list.
|
|
t.Logf("\n%s", out)
|
|
}
|
|
|
|
func TestFilterNarrows(t *testing.T) {
|
|
m := sampleModel()
|
|
m.query = "staging"
|
|
m.applyFilter()
|
|
if len(m.view) != 1 {
|
|
t.Fatalf("expected 1 match for 'staging', got %d", len(m.view))
|
|
}
|
|
if m.rows[m.view[0]].host.Alias != "staging-web" {
|
|
t.Errorf("wrong match: %s", m.rows[m.view[0]].host.Alias)
|
|
}
|
|
}
|
|
|
|
func TestFavoriteSortsFirst(t *testing.T) {
|
|
m := sampleModel()
|
|
if len(m.view) == 0 {
|
|
t.Fatal("no rows")
|
|
}
|
|
if !m.rows[m.view[0]].fav {
|
|
t.Errorf("favourite should be first, got %s", m.rows[m.view[0]].host.Alias)
|
|
}
|
|
}
|
|
|
|
func TestAlphaSort(t *testing.T) {
|
|
m := sampleModel()
|
|
m.sortM = store.SortAlpha
|
|
m.sortAll()
|
|
m.applyFilter()
|
|
// Favourite (bastion) still pins first; the rest are alphabetical.
|
|
got := []string{}
|
|
for _, vi := range m.view {
|
|
got = append(got, m.rows[vi].host.Alias)
|
|
}
|
|
want := []string{"bastion", "db-primary", "staging-web", "web1"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Errorf("alpha order = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSortCycle(t *testing.T) {
|
|
s := store.SortRecent
|
|
seq := []store.SortMode{}
|
|
for i := 0; i < 3; i++ {
|
|
s = s.Next()
|
|
seq = append(seq, s)
|
|
}
|
|
want := []store.SortMode{store.SortFrequent, store.SortAlpha, store.SortRecent}
|
|
for i := range want {
|
|
if seq[i] != want[i] {
|
|
t.Errorf("cycle[%d] = %s, want %s", i, seq[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGrouping(t *testing.T) {
|
|
m := sampleModel()
|
|
m.grouped = true
|
|
m.applyFilter()
|
|
|
|
// Expect a Favourites header first, then tag sections, then no untagged
|
|
// (bastion, the only untagged host, is a favourite).
|
|
if h, ok := m.headers[0]; !ok || !strings.Contains(h.title, "favourites") {
|
|
t.Fatalf("first section should be favourites, got %+v", m.headers[0])
|
|
}
|
|
// bastion (fav) appears once, only in the favourites section.
|
|
count := 0
|
|
for _, vi := range m.view {
|
|
if m.rows[vi].host.Alias == "bastion" {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("favourite host should appear exactly once, got %d", count)
|
|
}
|
|
// Every non-header host is reachable; view length equals host count (no dupes).
|
|
if len(m.view) != len(m.rows) {
|
|
t.Errorf("grouped view should contain each host once: got %d, want %d", len(m.view), len(m.rows))
|
|
}
|
|
// A tag section header should carry the tag as its colour key.
|
|
foundProd := false
|
|
for _, h := range m.headers {
|
|
if h.title == "prod" && h.tag == "prod" {
|
|
foundProd = true
|
|
}
|
|
}
|
|
if !foundProd {
|
|
t.Error("expected a 'prod' tag section header")
|
|
}
|
|
}
|
|
|
|
func TestNoOverflow(t *testing.T) {
|
|
for _, grouped := range []bool{false, true} {
|
|
for _, w := range []int{40, 60, 90, 140} {
|
|
for _, h := range []int{18, 24, 40} {
|
|
m := sampleModel()
|
|
m.grouped = grouped
|
|
m.applyFilter()
|
|
m.width, m.height = w, h
|
|
out := m.View()
|
|
lines := strings.Split(out, "\n")
|
|
if len(lines) > h {
|
|
t.Errorf("grouped=%v %dx%d: %d lines > height", grouped, w, h, len(lines))
|
|
}
|
|
for _, ln := range lines {
|
|
if lw := lipgloss.Width(ln); lw > w {
|
|
t.Errorf("grouped=%v %dx%d: line %d cols > width", grouped, w, h, lw)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|