This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/shell/cisco/cisco_test.go
Torjus Håkestad 5ba62afec3 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>
2026-02-15 14:58:26 +01:00

532 lines
14 KiB
Go

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
}