Initial commit: dial — interactive SSH host picker
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>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user