feat: add Cisco IOS shell with mode state machine and abbreviation matching (PLAN.md 3.2)
Implements a Cisco IOS CLI emulator with four modes (user exec, privileged exec, global config, interface config), Cisco-style command abbreviation (e.g. sh run, conf t), enable password flow, and realistic show command output including running-config, interfaces, IP routes, and VLANs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
531
internal/shell/cisco/cisco_test.go
Normal file
531
internal/shell/cisco/cisco_test.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package cisco
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- Abbreviation resolution tests ---
|
||||
|
||||
func TestResolveAbbreviationExact(t *testing.T) {
|
||||
entries := []commandEntry{
|
||||
{name: "show"},
|
||||
{name: "shutdown"},
|
||||
}
|
||||
got, err := resolveAbbreviation("show", entries)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "show" {
|
||||
t.Errorf("got %q, want %q", got, "show")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAbbreviationUnique(t *testing.T) {
|
||||
entries := []commandEntry{
|
||||
{name: "show"},
|
||||
{name: "enable"},
|
||||
{name: "exit"},
|
||||
}
|
||||
got, err := resolveAbbreviation("sh", entries)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "show" {
|
||||
t.Errorf("got %q, want %q", got, "show")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAbbreviationAmbiguous(t *testing.T) {
|
||||
entries := []commandEntry{
|
||||
{name: "show"},
|
||||
{name: "shutdown"},
|
||||
}
|
||||
_, err := resolveAbbreviation("sh", entries)
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous error, got nil")
|
||||
}
|
||||
if err.Error() != "ambiguous" {
|
||||
t.Errorf("got error %q, want %q", err.Error(), "ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAbbreviationUnknown(t *testing.T) {
|
||||
entries := []commandEntry{
|
||||
{name: "show"},
|
||||
{name: "enable"},
|
||||
}
|
||||
_, err := resolveAbbreviation("xyz", entries)
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown error, got nil")
|
||||
}
|
||||
if err.Error() != "unknown" {
|
||||
t.Errorf("got error %q, want %q", err.Error(), "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAbbreviationCaseInsensitive(t *testing.T) {
|
||||
entries := []commandEntry{
|
||||
{name: "show"},
|
||||
{name: "enable"},
|
||||
}
|
||||
got, err := resolveAbbreviation("SH", entries)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "show" {
|
||||
t.Errorf("got %q, want %q", got, "show")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-word command resolution tests ---
|
||||
|
||||
func TestResolveCommandShowRunningConfig(t *testing.T) {
|
||||
resolved, args, err := resolveCommand([]string{"sh", "run"}, privilegedExecCommands)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Errorf("unexpected args: %v", args)
|
||||
}
|
||||
want := []string{"show", "running-config"}
|
||||
if len(resolved) != len(want) {
|
||||
t.Fatalf("resolved = %v, want %v", resolved, want)
|
||||
}
|
||||
for i := range want {
|
||||
if resolved[i] != want[i] {
|
||||
t.Errorf("resolved[%d] = %q, want %q", i, resolved[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCommandConfigureTerminal(t *testing.T) {
|
||||
resolved, _, err := resolveCommand([]string{"conf", "t"}, privilegedExecCommands)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"configure", "terminal"}
|
||||
if len(resolved) != len(want) {
|
||||
t.Fatalf("resolved = %v, want %v", resolved, want)
|
||||
}
|
||||
for i := range want {
|
||||
if resolved[i] != want[i] {
|
||||
t.Errorf("resolved[%d] = %q, want %q", i, resolved[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCommandShowIPInterfaceBrief(t *testing.T) {
|
||||
resolved, _, err := resolveCommand([]string{"sh", "ip", "int", "br"}, privilegedExecCommands)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"show", "ip", "interface", "brief"}
|
||||
if len(resolved) != len(want) {
|
||||
t.Fatalf("resolved = %v, want %v", resolved, want)
|
||||
}
|
||||
for i := range want {
|
||||
if resolved[i] != want[i] {
|
||||
t.Errorf("resolved[%d] = %q, want %q", i, resolved[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCommandWithArgs(t *testing.T) {
|
||||
// "hostname MyRouter" → resolved=["hostname"], args=["MyRouter"]
|
||||
resolved, args, err := resolveCommand([]string{"hostname", "MyRouter"}, globalConfigCommands)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(resolved) != 1 || resolved[0] != "hostname" {
|
||||
t.Errorf("resolved = %v, want [hostname]", resolved)
|
||||
}
|
||||
if len(args) != 1 || args[0] != "MyRouter" {
|
||||
t.Errorf("args = %v, want [MyRouter]", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCommandAmbiguous(t *testing.T) {
|
||||
// In user exec, "e" matches "enable" and "exit" — ambiguous
|
||||
_, _, err := resolveCommand([]string{"e"}, userExecCommands)
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mode state machine tests ---
|
||||
|
||||
func TestPromptGeneration(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode iosMode
|
||||
want string
|
||||
}{
|
||||
{modeUserExec, "Router>"},
|
||||
{modePrivilegedExec, "Router#"},
|
||||
{modeGlobalConfig, "Router(config)#"},
|
||||
{modeInterfaceConfig, "Router(config-if)#"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
s.mode = tt.mode
|
||||
if got := s.prompt(); got != tt.want {
|
||||
t.Errorf("prompt(%d) = %q, want %q", tt.mode, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptAfterHostnameChange(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
s.mode = modeGlobalConfig
|
||||
s.dispatch("hostname Switch1")
|
||||
if s.hostname != "Switch1" {
|
||||
t.Fatalf("hostname = %q, want %q", s.hostname, "Switch1")
|
||||
}
|
||||
if got := s.prompt(); got != "Switch1(config)#" {
|
||||
t.Errorf("prompt = %q, want %q", got, "Switch1(config)#")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeTransitions(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
|
||||
// Start in user exec.
|
||||
if s.mode != modeUserExec {
|
||||
t.Fatalf("initial mode = %d, want %d", s.mode, modeUserExec)
|
||||
}
|
||||
|
||||
// Can't skip to config mode directly from user exec.
|
||||
result := s.dispatch("configure terminal")
|
||||
if result.output == "" {
|
||||
t.Error("expected error for conf t in user exec mode")
|
||||
}
|
||||
|
||||
// Manually set privileged mode (enable tested separately).
|
||||
s.mode = modePrivilegedExec
|
||||
|
||||
// conf t → global config
|
||||
s.dispatch("configure terminal")
|
||||
if s.mode != modeGlobalConfig {
|
||||
t.Errorf("mode after conf t = %d, want %d", s.mode, modeGlobalConfig)
|
||||
}
|
||||
|
||||
// interface Gi0/0 → interface config
|
||||
s.dispatch("interface GigabitEthernet0/0")
|
||||
if s.mode != modeInterfaceConfig {
|
||||
t.Errorf("mode after interface = %d, want %d", s.mode, modeInterfaceConfig)
|
||||
}
|
||||
|
||||
// exit → back to global config
|
||||
s.dispatch("exit")
|
||||
if s.mode != modeGlobalConfig {
|
||||
t.Errorf("mode after exit from if-config = %d, want %d", s.mode, modeGlobalConfig)
|
||||
}
|
||||
|
||||
// end → back to privileged exec
|
||||
s.dispatch("end")
|
||||
if s.mode != modePrivilegedExec {
|
||||
t.Errorf("mode after end = %d, want %d", s.mode, modePrivilegedExec)
|
||||
}
|
||||
|
||||
// disable → back to user exec
|
||||
s.dispatch("disable")
|
||||
if s.mode != modeUserExec {
|
||||
t.Errorf("mode after disable = %d, want %d", s.mode, modeUserExec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndFromInterfaceConfig(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
s.mode = modeInterfaceConfig
|
||||
s.currentIf = "GigabitEthernet0/0"
|
||||
|
||||
s.dispatch("end")
|
||||
if s.mode != modePrivilegedExec {
|
||||
t.Errorf("mode after end = %d, want %d", s.mode, modePrivilegedExec)
|
||||
}
|
||||
if s.currentIf != "" {
|
||||
t.Errorf("currentIf = %q, want empty", s.currentIf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitFromPrivilegedExec(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
s.mode = modePrivilegedExec
|
||||
result := s.dispatch("exit")
|
||||
if !result.exit {
|
||||
t.Error("expected exit=true from privileged exec exit")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Show command output tests ---
|
||||
|
||||
func TestShowVersionContainsModel(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
output := showVersion(s)
|
||||
if !contains(output, "C2960") {
|
||||
t.Error("show version missing model")
|
||||
}
|
||||
if !contains(output, "15.0(2)SE11") {
|
||||
t.Error("show version missing IOS version")
|
||||
}
|
||||
if !contains(output, "Router") {
|
||||
t.Error("show version missing hostname")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowRunningConfigContainsInterfaces(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
output := showRunningConfig(s)
|
||||
if !contains(output, "hostname Router") {
|
||||
t.Error("running-config missing hostname")
|
||||
}
|
||||
if !contains(output, "interface GigabitEthernet0/0") {
|
||||
t.Error("running-config missing interface")
|
||||
}
|
||||
if !contains(output, "ip address 192.168.1.1") {
|
||||
t.Error("running-config missing IP address")
|
||||
}
|
||||
if !contains(output, "line vty") {
|
||||
t.Error("running-config missing VTY config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowRunningConfigWithEnableSecret(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "secret123")
|
||||
output := showRunningConfig(s)
|
||||
if !contains(output, "enable secret") {
|
||||
t.Error("running-config missing enable secret when password is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowRunningConfigWithoutEnableSecret(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
output := showRunningConfig(s)
|
||||
if contains(output, "enable secret") {
|
||||
t.Error("running-config should not have enable secret when password is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowIPInterfaceBrief(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
output := showIPInterfaceBrief(s)
|
||||
if !contains(output, "GigabitEthernet0/0") {
|
||||
t.Error("ip interface brief missing GigabitEthernet0/0")
|
||||
}
|
||||
if !contains(output, "192.168.1.1") {
|
||||
t.Error("ip interface brief missing 192.168.1.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowIPRoute(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
output := showIPRoute(s)
|
||||
if !contains(output, "directly connected") {
|
||||
t.Error("ip route missing connected routes")
|
||||
}
|
||||
if !contains(output, "0.0.0.0/0") {
|
||||
t.Error("ip route missing default route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowVLANBrief(t *testing.T) {
|
||||
output := showVLANBrief()
|
||||
if !contains(output, "default") {
|
||||
t.Error("vlan brief missing default vlan")
|
||||
}
|
||||
if !contains(output, "MGMT") {
|
||||
t.Error("vlan brief missing MGMT vlan")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Interface config tests ---
|
||||
|
||||
func TestInterfaceShutdownNoShutdown(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
s.mode = modeInterfaceConfig
|
||||
s.currentIf = "GigabitEthernet0/0"
|
||||
|
||||
s.dispatch("shutdown")
|
||||
iface := s.findInterface("GigabitEthernet0/0")
|
||||
if iface == nil {
|
||||
t.Fatal("interface not found")
|
||||
}
|
||||
if !iface.shutdown {
|
||||
t.Error("interface should be shutdown")
|
||||
}
|
||||
if iface.status != "administratively down" {
|
||||
t.Errorf("status = %q, want %q", iface.status, "administratively down")
|
||||
}
|
||||
|
||||
s.dispatch("no shutdown")
|
||||
if iface.shutdown {
|
||||
t.Error("interface should not be shutdown after no shutdown")
|
||||
}
|
||||
if iface.status != "up" {
|
||||
t.Errorf("status = %q, want %q", iface.status, "up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceIPAddress(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
s.mode = modeInterfaceConfig
|
||||
s.currentIf = "GigabitEthernet0/0"
|
||||
|
||||
s.dispatch("ip address 10.10.10.1 255.255.255.0")
|
||||
iface := s.findInterface("GigabitEthernet0/0")
|
||||
if iface == nil {
|
||||
t.Fatal("interface not found")
|
||||
}
|
||||
if iface.ip != "10.10.10.1" {
|
||||
t.Errorf("ip = %q, want %q", iface.ip, "10.10.10.1")
|
||||
}
|
||||
if iface.mask != "255.255.255.0" {
|
||||
t.Errorf("mask = %q, want %q", iface.mask, "255.255.255.0")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dispatch / invalid command tests ---
|
||||
|
||||
func TestInvalidCommandInUserExec(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
result := s.dispatch("foobar")
|
||||
if !contains(result.output, "Invalid input") {
|
||||
t.Errorf("expected invalid input error, got %q", result.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmbiguousCommandOutput(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
// "e" in user exec is ambiguous (enable, exit)
|
||||
result := s.dispatch("e")
|
||||
if !contains(result.output, "Ambiguous") {
|
||||
t.Errorf("expected ambiguous error, got %q", result.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpCommand(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
result := s.dispatch("?")
|
||||
if !contains(result.output, "show") {
|
||||
t.Error("help missing 'show'")
|
||||
}
|
||||
if !contains(result.output, "enable") {
|
||||
t.Error("help missing 'enable'")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Abbreviation integration tests ---
|
||||
|
||||
func TestShowAbbreviationInDispatch(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
s.mode = modePrivilegedExec
|
||||
result := s.dispatch("sh ver")
|
||||
if !contains(result.output, "Cisco IOS Software") {
|
||||
t.Error("'sh ver' should produce version output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfTAbbreviation(t *testing.T) {
|
||||
s := newIOSState("Router", "C2960", "15.0(2)SE11", "")
|
||||
s.mode = modePrivilegedExec
|
||||
s.dispatch("conf t")
|
||||
if s.mode != modeGlobalConfig {
|
||||
t.Errorf("mode after conf t = %d, want %d", s.mode, modeGlobalConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Enable command detection ---
|
||||
|
||||
func TestIsEnableCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"enable", true},
|
||||
{"en", true},
|
||||
{"ena", true},
|
||||
{"e", false}, // too short (single char could be other commands)
|
||||
{"enab", true},
|
||||
{"ENABLE", true},
|
||||
{"exit", false},
|
||||
{"enable 15", false}, // has extra argument
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isEnableCommand(tt.input); got != tt.want {
|
||||
t.Errorf("isEnableCommand(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- configString tests ---
|
||||
|
||||
func TestConfigString(t *testing.T) {
|
||||
cfg := map[string]any{"hostname": "MySwitch"}
|
||||
if got := configString(cfg, "hostname", "Router"); got != "MySwitch" {
|
||||
t.Errorf("configString() = %q, want %q", got, "MySwitch")
|
||||
}
|
||||
if got := configString(cfg, "missing", "Default"); got != "Default" {
|
||||
t.Errorf("configString() for missing = %q, want %q", got, "Default")
|
||||
}
|
||||
if got := configString(nil, "key", "Default"); got != "Default" {
|
||||
t.Errorf("configString(nil) = %q, want %q", got, "Default")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
func TestMaskBits(t *testing.T) {
|
||||
tests := []struct {
|
||||
mask string
|
||||
want int
|
||||
}{
|
||||
{"255.255.255.0", 24},
|
||||
{"255.255.255.252", 30},
|
||||
{"255.255.0.0", 16},
|
||||
{"255.0.0.0", 8},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := maskBits(tt.mask); got != tt.want {
|
||||
t.Errorf("maskBits(%q) = %d, want %d", tt.mask, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkFromIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
ip, mask, want string
|
||||
}{
|
||||
{"192.168.1.1", "255.255.255.0", "192.168.1.0"},
|
||||
{"10.0.0.1", "255.255.255.252", "10.0.0.0"},
|
||||
{"172.16.5.100", "255.255.0.0", "172.16.0.0"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := networkFromIP(tt.ip, tt.mask); got != tt.want {
|
||||
t.Errorf("networkFromIP(%q, %q) = %q, want %q", tt.ip, tt.mask, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shell metadata ---
|
||||
|
||||
func TestShellNameAndDescription(t *testing.T) {
|
||||
s := NewCiscoShell()
|
||||
if s.Name() != "cisco" {
|
||||
t.Errorf("Name() = %q, want %q", s.Name(), "cisco")
|
||||
}
|
||||
if s.Description() == "" {
|
||||
t.Error("Description() should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && containsHelper(s, substr)
|
||||
}
|
||||
|
||||
func containsHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user