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:
2026-02-14 23:17:12 +01:00
parent 462c44ce89
commit 8ff029fcb7
18 changed files with 2466 additions and 8 deletions

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