Files
bsncubed 8af44dfcd2 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>
2026-07-22 23:18:23 +10:00

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)
}
}
}
}
}
}