feat: add Banking TUI shell using bubbletea
Add an 80s-style green-on-black bank terminal shell ("banking") using
charmbracelet/bubbletea for full-screen TUI rendering over SSH.
Screens: login, main menu, account summary, account detail with
transactions, wire transfer wizard (6-step form capturing routing
number, destination, beneficiary, amount, memo, auth code), transaction
history with pagination, secure messages with breadcrumb content (fake
internal IPs, vault codes), change PIN, and hidden admin access (99)
that locks after 3 failed attempts with COBOL-style error output.
All key actions (login, navigation, wire transfers, admin attempts) are
logged to the session store. Wire transfer data is the honeypot gold.
Configurable via [shell.banking] in TOML: bank_name, terminal_id, region.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
533
internal/shell/banking/banking_test.go
Normal file
533
internal/shell/banking/banking_test.go
Normal file
@@ -0,0 +1,533 @@
|
||||
package banking
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
)
|
||||
|
||||
// newTestModel creates a model with a test session context.
|
||||
func newTestModel(t *testing.T) (*model, *storage.MemoryStore) {
|
||||
t.Helper()
|
||||
store := storage.NewMemoryStore()
|
||||
sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "banker", "banking")
|
||||
sess := &shell.SessionContext{
|
||||
SessionID: sessID,
|
||||
Username: "banker",
|
||||
Store: store,
|
||||
}
|
||||
m := newModel(sess, "SECUREBANK", "SB-0001", "NORTHEAST")
|
||||
return m, store
|
||||
}
|
||||
|
||||
// sendKeys sends a string of characters as individual key messages to the model.
|
||||
func sendKeys(m *model, s string) {
|
||||
for _, ch := range s {
|
||||
var msg tea.KeyMsg
|
||||
switch ch {
|
||||
case '\r':
|
||||
msg = tea.KeyMsg{Type: tea.KeyEnter}
|
||||
case '\x1b':
|
||||
msg = tea.KeyMsg{Type: tea.KeyEscape}
|
||||
case '\x03':
|
||||
msg = tea.KeyMsg{Type: tea.KeyCtrlC}
|
||||
default:
|
||||
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}
|
||||
}
|
||||
m.Update(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBankingShellName(t *testing.T) {
|
||||
sh := NewBankingShell()
|
||||
if sh.Name() != "banking" {
|
||||
t.Errorf("Name() = %q, want %q", sh.Name(), "banking")
|
||||
}
|
||||
if sh.Description() == "" {
|
||||
t.Error("Description() should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCurrency(t *testing.T) {
|
||||
tests := []struct {
|
||||
cents int64
|
||||
want string
|
||||
}{
|
||||
{0, "$0.00"},
|
||||
{100, "$1.00"},
|
||||
{4738291, "$47,382.91"},
|
||||
{18254100, "$182,541.00"},
|
||||
{52387450, "$523,874.50"},
|
||||
{25000000, "$250,000.00"},
|
||||
{-125000, "-$1,250.00"},
|
||||
{99, "$0.99"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := formatCurrency(tt.cents)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatCurrency(%d) = %q, want %q", tt.cents, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBankState(t *testing.T) {
|
||||
state := newBankState()
|
||||
if len(state.Accounts) != 4 {
|
||||
t.Errorf("expected 4 accounts, got %d", len(state.Accounts))
|
||||
}
|
||||
for _, acct := range state.Accounts {
|
||||
txns, ok := state.Transactions[acct.Number]
|
||||
if !ok {
|
||||
t.Errorf("no transactions for account %s", acct.Number)
|
||||
continue
|
||||
}
|
||||
if len(txns) == 0 {
|
||||
t.Errorf("account %s has no transactions", acct.Number)
|
||||
}
|
||||
}
|
||||
if len(state.Messages) != 4 {
|
||||
t.Errorf("expected 4 messages, got %d", len(state.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginScreenRenders(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "SECUREBANK") {
|
||||
t.Error("login should show bank name")
|
||||
}
|
||||
if !strings.Contains(view, "AUTHORIZED ACCESS ONLY") {
|
||||
t.Error("login should show authorization warning")
|
||||
}
|
||||
if !strings.Contains(view, "ACCOUNT NUMBER") {
|
||||
t.Error("login should prompt for account number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFlow(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
|
||||
// Type account number.
|
||||
sendKeys(m, "12345678")
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "12345678") {
|
||||
t.Error("should show typed account number")
|
||||
}
|
||||
|
||||
// Press enter.
|
||||
sendKeys(m, "\r")
|
||||
view = m.View()
|
||||
if !strings.Contains(view, "PIN") {
|
||||
t.Error("should show PIN prompt after entering account number")
|
||||
}
|
||||
|
||||
// Type PIN and enter.
|
||||
sendKeys(m, "1234\r")
|
||||
|
||||
// Should be on menu now.
|
||||
if m.screen != screenMenu {
|
||||
t.Errorf("expected screenMenu, got %d", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainMenuRenders(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r")
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "MAIN MENU") {
|
||||
t.Error("should show MAIN MENU after login")
|
||||
}
|
||||
if !strings.Contains(view, "WIRE TRANSFER") {
|
||||
t.Error("menu should contain WIRE TRANSFER option")
|
||||
}
|
||||
if !strings.Contains(view, "SECURE MESSAGES") {
|
||||
t.Error("menu should contain SECURE MESSAGES option")
|
||||
}
|
||||
if !strings.Contains(view, "ACCOUNT SUMMARY") {
|
||||
t.Error("menu should contain ACCOUNT SUMMARY option")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSummary(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r") // login
|
||||
sendKeys(m, "1\r") // account summary
|
||||
|
||||
if m.screen != screenAccountSummary {
|
||||
t.Fatalf("expected screenAccountSummary, got %d", m.screen)
|
||||
}
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "ACCOUNT SUMMARY") {
|
||||
t.Error("should show ACCOUNT SUMMARY")
|
||||
}
|
||||
if !strings.Contains(view, "CHECKING") {
|
||||
t.Error("should show CHECKING account")
|
||||
}
|
||||
if !strings.Contains(view, "SAVINGS") {
|
||||
t.Error("should show SAVINGS account")
|
||||
}
|
||||
if !strings.Contains(view, "TOTAL") {
|
||||
t.Error("should show TOTAL")
|
||||
}
|
||||
|
||||
// Press any key to return.
|
||||
sendKeys(m, " ")
|
||||
if m.screen != screenMenu {
|
||||
t.Errorf("should return to menu, got screen %d", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireTransferFlow(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r") // login
|
||||
sendKeys(m, "3\r") // wire transfer
|
||||
|
||||
if m.screen != screenTransfer {
|
||||
t.Fatalf("expected screenTransfer, got %d", m.screen)
|
||||
}
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "WIRE TRANSFER") {
|
||||
t.Error("should show WIRE TRANSFER header")
|
||||
}
|
||||
|
||||
// Fill all fields.
|
||||
sendKeys(m, "021000021\r") // routing
|
||||
sendKeys(m, "9876543210\r") // dest account
|
||||
sendKeys(m, "JOHN DOE\r") // beneficiary
|
||||
sendKeys(m, "FIRST NATIONAL BANK\r") // bank name
|
||||
sendKeys(m, "50000\r") // amount
|
||||
sendKeys(m, "INVOICE 12345\r") // memo
|
||||
|
||||
// Should be on confirm step.
|
||||
view = m.View()
|
||||
if !strings.Contains(view, "TRANSFER SUMMARY") {
|
||||
t.Error("should show TRANSFER SUMMARY for confirmation")
|
||||
}
|
||||
if !strings.Contains(view, "021000021") {
|
||||
t.Error("summary should show routing number")
|
||||
}
|
||||
|
||||
// Confirm.
|
||||
sendKeys(m, "Y\r")
|
||||
|
||||
// Auth code.
|
||||
sendKeys(m, "AUTH99\r")
|
||||
|
||||
view = m.View()
|
||||
if !strings.Contains(view, "TRANSFER QUEUED") {
|
||||
t.Error("should show TRANSFER QUEUED confirmation")
|
||||
}
|
||||
|
||||
// Check wire transfer was stored.
|
||||
if len(m.state.Transfers) != 1 {
|
||||
t.Fatalf("expected 1 transfer, got %d", len(m.state.Transfers))
|
||||
}
|
||||
wt := m.state.Transfers[0]
|
||||
if wt.RoutingNumber != "021000021" {
|
||||
t.Errorf("routing = %q, want %q", wt.RoutingNumber, "021000021")
|
||||
}
|
||||
if wt.DestAccount != "9876543210" {
|
||||
t.Errorf("dest = %q, want %q", wt.DestAccount, "9876543210")
|
||||
}
|
||||
if wt.Beneficiary != "JOHN DOE" {
|
||||
t.Errorf("beneficiary = %q, want %q", wt.Beneficiary, "JOHN DOE")
|
||||
}
|
||||
|
||||
// Press key to return to menu.
|
||||
sendKeys(m, " ")
|
||||
if m.screen != screenMenu {
|
||||
t.Errorf("should return to menu after transfer, got screen %d", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireTransferCancel(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r") // login
|
||||
sendKeys(m, "3\r") // wire transfer
|
||||
sendKeys(m, "021000021\r")
|
||||
sendKeys(m, "9876543210\r")
|
||||
sendKeys(m, "JOHN DOE\r")
|
||||
sendKeys(m, "FIRST NATIONAL BANK\r")
|
||||
sendKeys(m, "50000\r")
|
||||
sendKeys(m, "INVOICE 12345\r")
|
||||
|
||||
// Cancel at confirm step.
|
||||
sendKeys(m, "N\r")
|
||||
|
||||
if m.screen != screenMenu {
|
||||
t.Errorf("should return to menu after cancel, got screen %d", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionHistory(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r") // login
|
||||
sendKeys(m, "4\r") // transaction history
|
||||
|
||||
if m.screen != screenHistory {
|
||||
t.Fatalf("expected screenHistory, got %d", m.screen)
|
||||
}
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "TRANSACTION HISTORY") {
|
||||
t.Error("should show TRANSACTION HISTORY header")
|
||||
}
|
||||
|
||||
// Select first account.
|
||||
sendKeys(m, "1\r")
|
||||
view = m.View()
|
||||
if !strings.Contains(view, "DATE") {
|
||||
t.Error("should show transaction list with DATE column")
|
||||
}
|
||||
if !strings.Contains(view, "PAGE") {
|
||||
t.Error("should show page indicator")
|
||||
}
|
||||
|
||||
// Press B to go back to account list.
|
||||
sendKeys(m, "B")
|
||||
view = m.View()
|
||||
if !strings.Contains(view, "SELECT ACCOUNT") {
|
||||
t.Error("should return to account selection")
|
||||
}
|
||||
|
||||
// Press 0 to return to menu.
|
||||
sendKeys(m, "0\r")
|
||||
if m.screen != screenMenu {
|
||||
t.Errorf("should return to menu, got screen %d", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureMessages(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r") // login
|
||||
sendKeys(m, "5\r") // secure messages
|
||||
|
||||
if m.screen != screenMessages {
|
||||
t.Fatalf("expected screenMessages, got %d", m.screen)
|
||||
}
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "SECURE MESSAGES") {
|
||||
t.Error("should show SECURE MESSAGES header")
|
||||
}
|
||||
if !strings.Contains(view, "SCHEDULED MAINTENANCE") {
|
||||
t.Error("should show first message subject")
|
||||
}
|
||||
|
||||
// View first message.
|
||||
sendKeys(m, "1\r")
|
||||
view = m.View()
|
||||
if !strings.Contains(view, "10.48.2.100") {
|
||||
t.Error("message body should contain breadcrumb IP")
|
||||
}
|
||||
|
||||
// Press key to return to list.
|
||||
sendKeys(m, " ")
|
||||
|
||||
// Return to menu.
|
||||
sendKeys(m, "0\r")
|
||||
if m.screen != screenMenu {
|
||||
t.Errorf("should return to menu, got screen %d", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAccessDenied(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r") // login
|
||||
sendKeys(m, "99\r") // admin
|
||||
|
||||
if m.screen != screenAdmin {
|
||||
t.Fatalf("expected screenAdmin, got %d", m.screen)
|
||||
}
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "SYSTEM ADMINISTRATION") {
|
||||
t.Error("should show SYSTEM ADMINISTRATION header")
|
||||
}
|
||||
|
||||
// Three failed PIN attempts.
|
||||
sendKeys(m, "secret1\r")
|
||||
view = m.View()
|
||||
if !strings.Contains(view, "INVALID CREDENTIALS") {
|
||||
t.Error("should show INVALID CREDENTIALS after first attempt")
|
||||
}
|
||||
|
||||
sendKeys(m, "secret2\r")
|
||||
sendKeys(m, "secret3\r")
|
||||
|
||||
view = m.View()
|
||||
if !strings.Contains(view, "ACCESS DENIED") {
|
||||
t.Error("should show ACCESS DENIED after 3 attempts")
|
||||
}
|
||||
if !strings.Contains(view, "ABEND S0C4") {
|
||||
t.Error("should show COBOL-style error")
|
||||
}
|
||||
|
||||
// Press key to return to menu.
|
||||
sendKeys(m, " ")
|
||||
if m.screen != screenMenu {
|
||||
t.Errorf("should return to menu after lockout, got screen %d", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEscapeReturns(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r") // login
|
||||
sendKeys(m, "99\r") // admin
|
||||
sendKeys(m, "\x1b") // ESC to return
|
||||
|
||||
if m.screen != screenMenu {
|
||||
t.Errorf("ESC should return to menu from admin, got screen %d", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePin(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r") // login
|
||||
sendKeys(m, "6\r") // change PIN
|
||||
|
||||
if m.screen != screenChangePin {
|
||||
t.Fatalf("expected screenChangePin, got %d", m.screen)
|
||||
}
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, "CHANGE PIN") {
|
||||
t.Error("should show CHANGE PIN header")
|
||||
}
|
||||
|
||||
// Old PIN.
|
||||
sendKeys(m, "1234\r")
|
||||
// New PIN.
|
||||
sendKeys(m, "5678\r")
|
||||
// Confirm PIN.
|
||||
sendKeys(m, "5678\r")
|
||||
|
||||
view = m.View()
|
||||
if !strings.Contains(view, "PIN CHANGED SUCCESSFULLY") {
|
||||
t.Error("should show PIN CHANGED SUCCESSFULLY")
|
||||
}
|
||||
|
||||
// Press key to return.
|
||||
sendKeys(m, " ")
|
||||
if m.screen != screenMenu {
|
||||
t.Errorf("should return to menu after PIN change, got screen %d", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutExits(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
sendKeys(m, "12345678\r1234\r") // login
|
||||
sendKeys(m, "7\r") // logout
|
||||
|
||||
if !m.quitting {
|
||||
t.Error("should be quitting after logout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLogs(t *testing.T) {
|
||||
m, store := newTestModel(t)
|
||||
|
||||
// Send login keys and manually run returned commands.
|
||||
// Type account number.
|
||||
sendKeys(m, "12345678")
|
||||
// Enter to advance to PIN.
|
||||
sendKeys(m, "\r")
|
||||
// Type PIN.
|
||||
sendKeys(m, "1234")
|
||||
// Enter to login — this returns a logAction cmd.
|
||||
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if cmd != nil {
|
||||
// Execute the batch of commands (login log).
|
||||
execCmds(cmd)
|
||||
}
|
||||
|
||||
// Give async store writes a moment.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if len(store.SessionLogs) == 0 {
|
||||
t.Error("expected session logs to be recorded after login")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, log := range store.SessionLogs {
|
||||
if strings.Contains(log.Input, "LOGIN") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected a LOGIN entry in session logs")
|
||||
}
|
||||
|
||||
// Navigate to account summary — also returns a logAction cmd.
|
||||
sendKeys(m, "1")
|
||||
_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if cmd != nil {
|
||||
execCmds(cmd)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
foundMenu := false
|
||||
for _, log := range store.SessionLogs {
|
||||
if strings.Contains(log.Input, "MENU") {
|
||||
foundMenu = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundMenu {
|
||||
t.Error("expected a MENU entry in session logs for account summary")
|
||||
}
|
||||
}
|
||||
|
||||
// execCmds recursively executes tea.Cmd functions (including batches).
|
||||
func execCmds(cmd tea.Cmd) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
msg := cmd()
|
||||
// tea.BatchMsg is a slice of Cmds returned by tea.Batch.
|
||||
if batch, ok := msg.(tea.BatchMsg); ok {
|
||||
for _, c := range batch {
|
||||
execCmds(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigString(t *testing.T) {
|
||||
cfg := map[string]any{
|
||||
"bank_name": "TESTBANK",
|
||||
"region": "SOUTHWEST",
|
||||
}
|
||||
if got := configString(cfg, "bank_name", "DEFAULT"); got != "TESTBANK" {
|
||||
t.Errorf("configString() = %q, want %q", got, "TESTBANK")
|
||||
}
|
||||
if got := configString(cfg, "missing", "DEFAULT"); got != "DEFAULT" {
|
||||
t.Errorf("configString() = %q, want %q", got, "DEFAULT")
|
||||
}
|
||||
if got := configString(nil, "bank_name", "DEFAULT"); got != "DEFAULT" {
|
||||
t.Errorf("configString(nil) = %q, want %q", got, "DEFAULT")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenFrame(t *testing.T) {
|
||||
frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here")
|
||||
if !strings.Contains(frame, "TESTBANK FEDERAL RESERVE SYSTEM") {
|
||||
t.Error("frame should contain bank name in header")
|
||||
}
|
||||
if !strings.Contains(frame, "TB-0001") {
|
||||
t.Error("frame should contain terminal ID in footer")
|
||||
}
|
||||
if !strings.Contains(frame, "content here") {
|
||||
t.Error("frame should contain the content")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user