Files

3894 lines
103 KiB
Go

package main
//This file was entirely AI managed
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/BurntSushi/toml"
"github.com/racingmars/go3270"
"golang.org/x/crypto/bcrypt"
)
// TestBackgroundSessionBuffering verifies that:
// 1. When a session is in the background (ActiveSlotIdx == -1), updates from upstream
// do NOT force themselves onto the client connection.
// 2. Updates are properly buffered in slot.LastScreen.
// 3. When the session is reconnected, the buffered screen updates are sent to the client.
func TestBackgroundSessionBuffering(t *testing.T) {
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'H', 'E', 'L', 'L', 'O', IAC, EOR} // EW command + data + IAC EOR
slot := &SessionSlot{
System: TargetSystem{Name: "Test System"},
Upstream: upClient,
LastScreen: initialScreen,
}
userSession := &UserMasterSession{
Username: "TESTUSER",
SessionKey: "dummykey",
DisplayTag: "12345678",
TerminalType: "IBM-3278-2",
ClientConn: clientServer,
ActiveSlotIdx: -1, // Session is currently in background / menu
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(userSession, slot, 0)
// Send an update while session is in background
updatePacket := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x50, 'W', 'O', 'R', 'L', 'D', IAC, EOR} // W command + data + IAC EOR
doneWrite := make(chan struct{})
go func() {
_, _ = upServer.Write(updatePacket)
close(doneWrite)
}()
select {
case <-doneWrite:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for upstream write")
}
// Give upstreamReader a moment to process
time.Sleep(100 * time.Millisecond)
// Verify nothing was written to the client connection
buf := make([]byte, 256)
_ = clientUser.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
n, err := clientUser.Read(buf)
if err == nil && n > 0 {
t.Fatalf("Expected NO data on client connection while session is in background, but got %d bytes: %x", n, buf[:n])
}
// Verify that the update WAS buffered in slot.LastScreen
slot.Mu.Lock()
lastScreen := make([]byte, len(slot.LastScreen))
copy(lastScreen, slot.LastScreen)
slot.Mu.Unlock()
expectedCombined := append(append([]byte{}, initialScreen...), updatePacket...)
if !bytes.Equal(lastScreen, expectedCombined) {
t.Fatalf("Buffered LastScreen does not match expected combined records.\nGot: %x\nExpected: %x", lastScreen, expectedCombined)
}
// Now simulate reconnecting: user enters session
userSession.Mu.Lock()
userSession.ActiveSlotIdx = 0
userSession.Mu.Unlock()
// Writing slot.LastScreen to clientConn (as connectOrResumeSlot does)
go func() {
slot.Mu.Lock()
_, _ = userSession.ClientConn.Write(slot.LastScreen)
slot.Mu.Unlock()
}()
_ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second))
reconnectedData := make([]byte, len(expectedCombined))
_, err = io.ReadFull(clientUser, reconnectedData)
if err != nil {
t.Fatalf("Failed to read buffered screen upon reconnect: %v", err)
}
if !bytes.Equal(reconnectedData, expectedCombined) {
t.Fatalf("Reconnected client received incorrect screen data.\nGot: %x\nExpected: %x", reconnectedData, expectedCombined)
}
// Now send a live update while session IS active
liveUpdate := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x60, 'L', 'I', 'V', 'E', IAC, EOR}
go func() {
_, _ = upServer.Write(liveUpdate)
}()
liveBuf := make([]byte, len(liveUpdate))
_, err = io.ReadFull(clientUser, liveBuf)
if err != nil {
t.Fatalf("Active session failed to forward live update: %v", err)
}
if !bytes.Equal(liveBuf, liveUpdate) {
t.Fatalf("Active session live update mismatch.\nGot: %x\nExpected: %x", liveBuf, liveUpdate)
}
}
// TestEraseWriteReplacesBuffer verifies that an Erase/Write command clears
// previously accumulated Write records instead of growing indefinitely.
func TestEraseWriteReplacesBuffer(t *testing.T) {
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
slot := &SessionSlot{
System: TargetSystem{Name: "TESTSYS"},
Upstream: upClient,
LastScreen: []byte{CMD_ERASE_WRITE, 0xC1, 'A', IAC, EOR},
}
userSession := &UserMasterSession{
Username: "USER1",
ClientConn: clientServer,
ActiveSlotIdx: -1,
Slots: []*SessionSlot{slot},
}
go upstreamReader(userSession, slot, 0)
// Send Write update
write1 := []byte{CMD_WRITE, 0xC1, 'B', IAC, EOR}
_, _ = upServer.Write(write1)
time.Sleep(50 * time.Millisecond)
// Now send a new full Erase/Write screen
newEW := []byte{CMD_ERASE_WRITE, 0xC2, 'C', IAC, EOR}
_, _ = upServer.Write(newEW)
time.Sleep(50 * time.Millisecond)
slot.Mu.Lock()
lastScreen := make([]byte, len(slot.LastScreen))
copy(lastScreen, slot.LastScreen)
slot.Mu.Unlock()
if !bytes.Equal(lastScreen, newEW) {
t.Fatalf("Expected slot.LastScreen to be replaced by new Erase/Write.\nGot: %x\nExpected: %x", lastScreen, newEW)
}
}
// TestMultiRecordFraming verifies that multiple records ending in IAC EOR within
// a single TCP packet are properly parsed and handled without dropping records.
func TestMultiRecordFraming(t *testing.T) {
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
slot := &SessionSlot{
System: TargetSystem{Name: "TESTSYS"},
Upstream: upClient,
LastScreen: nil,
}
userSession := &UserMasterSession{
Username: "USER1",
ClientConn: clientServer,
ActiveSlotIdx: -1,
Slots: []*SessionSlot{slot},
}
go upstreamReader(userSession, slot, 0)
rec1 := []byte{CMD_ERASE_WRITE, 0xC1, 'X', IAC, EOR}
rec2 := []byte{CMD_WRITE, 0xC2, 'Y', IAC, EOR}
combined := append(append([]byte{}, rec1...), rec2...)
_, _ = upServer.Write(combined)
time.Sleep(100 * time.Millisecond)
slot.Mu.Lock()
lastScreen := make([]byte, len(slot.LastScreen))
copy(lastScreen, slot.LastScreen)
slot.Mu.Unlock()
if !bytes.Equal(lastScreen, combined) {
t.Fatalf("Expected both records preserved in LastScreen.\nGot: %x\nExpected: %x", lastScreen, combined)
}
}
// TestMultipleSessionsBackgroundIsolation verifies that when Session 1 is active,
// background updates to Session 0 do NOT bleed into the client connection.
func TestMultipleSessionsBackgroundIsolation(t *testing.T) {
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer0, upClient0 := net.Pipe()
defer upServer0.Close()
defer upClient0.Close()
upServer1, upClient1 := net.Pipe()
defer upServer1.Close()
defer upClient1.Close()
slot0 := &SessionSlot{
System: TargetSystem{Name: "SYS0"},
Upstream: upClient0,
LastScreen: []byte{CMD_ERASE_WRITE, 0xC0, '0', IAC, EOR},
}
slot1 := &SessionSlot{
System: TargetSystem{Name: "SYS1"},
Upstream: upClient1,
LastScreen: []byte{CMD_ERASE_WRITE, 0xC1, '1', IAC, EOR},
}
userSession := &UserMasterSession{
Username: "USER1",
ClientConn: clientServer,
ActiveSlotIdx: 1, // Slot 1 is currently active! Slot 0 is in background!
Slots: []*SessionSlot{slot0, slot1},
}
go upstreamReader(userSession, slot0, 0)
go upstreamReader(userSession, slot1, 1)
// Send an update on Slot 0 (background)
up0Update := []byte{CMD_WRITE, 0xC0, 'U', 'P', 'D', '0', IAC, EOR}
_, _ = upServer0.Write(up0Update)
time.Sleep(50 * time.Millisecond)
// Send an update on Slot 1 (active)
up1Update := []byte{CMD_WRITE, 0xC1, 'A', 'C', 'T', '1', IAC, EOR}
go func() {
_, _ = upServer1.Write(up1Update)
}()
// The client should receive ONLY up1Update, NOT up0Update!
buf := make([]byte, len(up1Update))
_ = clientUser.SetReadDeadline(time.Now().Add(1 * time.Second))
_, err := io.ReadFull(clientUser, buf)
if err != nil {
t.Fatalf("Failed to read from active slot: %v", err)
}
if !bytes.Equal(buf, up1Update) {
t.Fatalf("Expected active slot data %x, but got %x", up1Update, buf)
}
// Verify nothing else was written
extraBuf := make([]byte, 100)
_ = clientUser.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
n, _ := clientUser.Read(extraBuf)
if n > 0 {
t.Fatalf("Unexpected extra data on client (Slot 0 leaked!): %x", extraBuf[:n])
}
// Verify Slot 0 DID buffer its update
slot0.Mu.Lock()
slot0Last := make([]byte, len(slot0.LastScreen))
copy(slot0Last, slot0.LastScreen)
slot0.Mu.Unlock()
expectedSlot0 := append([]byte{CMD_ERASE_WRITE, 0xC0, '0', IAC, EOR}, up0Update...)
if !bytes.Equal(slot0Last, expectedSlot0) {
t.Fatalf("Slot 0 failed to buffer update.\nGot: %x\nExpected: %x", slot0Last, expectedSlot0)
}
}
// TestPA3SwitchToBackground verifies that PA3 exits bridgeClientToUpstream
// and resets ActiveSlotIdx to -1.
func TestPA3SwitchToBackground(t *testing.T) {
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
slot := &SessionSlot{
System: TargetSystem{Name: "SYS"},
Upstream: upClient,
}
userSession := &UserMasterSession{
Username: "USER1",
ClientConn: clientServer,
ActiveSlotIdx: 0,
Slots: []*SessionSlot{slot},
}
bridgeDone := make(chan struct{})
go func() {
bridgeClientToUpstream(userSession, slot, clientServer)
close(bridgeDone)
}()
// Send PA3 from client
pa3Packet := []byte{byte(go3270.AIDPA3)}
_, _ = clientUser.Write(pa3Packet)
select {
case <-bridgeDone:
// Bridge exited properly on PA3
case <-time.After(2 * time.Second):
t.Fatal("bridgeClientToUpstream failed to return on PA3")
}
}
// TestBackgroundScreenNotice verifies that when a background session receives
// a screen update, its ScreenChanged flag is set.
func TestBackgroundScreenNotice(t *testing.T) {
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 'I', 'N', 'I', 'T', IAC, EOR}
slot := &SessionSlot{
System: TargetSystem{Name: "System 1"},
Upstream: upClient,
LastScreen: initialScreen,
}
slot.Mu.Lock()
initialChanged := slot.ScreenChanged
slot.Mu.Unlock()
if initialChanged {
t.Fatalf("Expected initial ScreenChanged to be false")
}
userSession := &UserMasterSession{
Username: "USER1",
ClientConn: clientServer,
ActiveSlotIdx: -1, // in background
Slots: []*SessionSlot{slot},
}
go upstreamReader(userSession, slot, 0)
// Send an update while in background
updatePacket := []byte{CMD_WRITE, 0xC3, 'U', 'P', 'D', IAC, EOR}
_, _ = upServer.Write(updatePacket)
time.Sleep(100 * time.Millisecond)
slot.Mu.Lock()
changedAfterUpdate := slot.ScreenChanged
slot.Mu.Unlock()
if !changedAfterUpdate {
t.Fatalf("Expected ScreenChanged to be true after background update")
}
}
func TestSend3270Message(t *testing.T) {
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
done := make(chan struct{})
go func() {
buf := make([]byte, 1024)
n, err := clientUser.Read(buf)
if err != nil {
t.Errorf("Read error: %v", err)
}
if n == 0 {
t.Errorf("Expected data from send3270Message, got 0 bytes")
}
close(done)
}()
send3270Message(clientServer, "Test Notification", nil)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for send3270Message output")
}
}
func TestUserMasterSessionCodepage(t *testing.T) {
s := &UserMasterSession{}
if s.Codepage() != nil {
t.Fatalf("Expected nil codepage when DevInfo is nil")
}
}
func generateTestSystems(n int) []TargetSystem {
systems := make([]TargetSystem, n)
for i := 0; i < n; i++ {
systems[i] = TargetSystem{
Name: fmt.Sprintf("SYS%02d", i+1),
Description: fmt.Sprintf("Test System #%d", i+1),
HostPort: "127.0.0.1:2323",
}
}
return systems
}
func TestMenuPaginationScreenLayout(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "TEST SESSION MANAGER",
PromptSelect: "Selection ===>",
Footer: "[1-N] Connect [PF7/PF8] Page [PF3] Disconnect",
},
}
ConfiguredSystem = generateTestSystems(99)
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
}
// 99 systems / 13 per page = 8 pages (pages 0 through 7)
for page := 0; page < 8; page++ {
s.Mu.Lock()
s.MenuPage = page
s.Mu.Unlock()
screen := buildMainMenuScreen(s)
// 1. Verify 24-line screen compatibility (no fields on Row < 0 or Row > 23)
// and verify Row 20 is blank (no fields on Row 20)
hasRow20 := false
var row21Fields []go3270.Field
var row22Fields []go3270.Field
var row23Fields []go3270.Field
systemRows := make(map[int]string)
for _, f := range screen {
if f.Row < 0 || f.Row > 23 {
t.Fatalf("Page %d: Field exceeds 24-line limit (Row %d): %+v", page+1, f.Row, f)
}
if f.Col < 0 || f.Col > 79 {
t.Fatalf("Page %d: Field exceeds 80-column limit (Col %d): %+v", page+1, f.Col, f)
}
if f.Row == 20 {
hasRow20 = true
}
if f.Row == 21 {
row21Fields = append(row21Fields, f)
}
if f.Row == 22 {
row22Fields = append(row22Fields, f)
}
if f.Row == 23 {
row23Fields = append(row23Fields, f)
}
if f.Row >= 7 && f.Row <= 19 {
systemRows[f.Row] = f.Content
}
}
if hasRow20 {
t.Fatalf("Page %d: Row 20 should be blank before Selection, but found fields on Row 20", page+1)
}
// 2. Verify Selection prompt on Row 21
if len(row21Fields) == 0 {
t.Fatalf("Page %d: Row 21 must contain Selection prompt", page+1)
}
if row21Fields[0].Content != "Selection ===>" {
t.Fatalf("Page %d: Row 21 prompt mismatch: got '%s'", page+1, row21Fields[0].Content)
}
// 3. Verify Page indicator on Row 22 on the far right
if len(row22Fields) != 1 {
t.Fatalf("Page %d: Expected exactly 1 field on Row 22 (page indicator), found %d", page+1, len(row22Fields))
}
expectedIndicator := fmt.Sprintf("Page %d of 8", page+1)
if row22Fields[0].Content != expectedIndicator {
t.Fatalf("Page %d: Indicator mismatch: got '%s', want '%s'", page+1, row22Fields[0].Content, expectedIndicator)
}
expectedCol := 79 - len(expectedIndicator)
if row22Fields[0].Col != expectedCol {
t.Fatalf("Page %d: Indicator Col mismatch: got %d, want %d (far right)", page+1, row22Fields[0].Col, expectedCol)
}
// 4. Verify Footer on Row 23
if len(row23Fields) == 0 {
t.Fatalf("Page %d: Row 23 must contain footer", page+1)
}
// 5. Verify system count and [%2d] formatting on this page
var expectedSystems int
if page < 7 {
expectedSystems = 13 // 13 systems per page
} else {
expectedSystems = 8 // remaining 99 - (7 * 13) = 8 systems on last page
}
if len(systemRows) != expectedSystems {
t.Fatalf("Page %d: Expected %d systems, found %d", page+1, expectedSystems, len(systemRows))
}
startIdx := page * 13
for i := 0; i < expectedSystems; i++ {
rowNum := 7 + i
content := systemRows[rowNum]
expectedSysNum := startIdx + i + 1
expectedPrefix := fmt.Sprintf("[%2d]", expectedSysNum)
if !strings.HasPrefix(content, expectedPrefix) {
t.Fatalf("Page %d, Row %d: Expected prefix '%s', got '%s'", page+1, rowNum, expectedPrefix, content)
}
}
}
}
func TestMenuPaginationBoundsAndClamping(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "TEST TITLE",
PromptSelect: "Selection ===>",
Footer: "Footer",
},
}
s := &UserMasterSession{
Username: "USER",
DisplayTag: "12345678",
}
findRow22 := func(screen go3270.Screen) (string, bool) {
for _, f := range screen {
if f.Row == 22 {
return f.Content, true
}
}
return "", false
}
// Case 1: 0 systems -> 1 page, no Row 22 indicator rendered
ConfiguredSystem = nil
s.MenuPage = 0
if content, ok := findRow22(buildMainMenuScreen(s)); ok {
t.Fatalf("Expected no Row 22 page indicator for 0 systems, got '%s'", content)
}
// Case 2: 13 systems -> 1 page, no Row 22 indicator rendered
ConfiguredSystem = generateTestSystems(13)
s.MenuPage = 0
if content, ok := findRow22(buildMainMenuScreen(s)); ok {
t.Fatalf("Expected no Row 22 page indicator for 13 systems, got '%s'", content)
}
// Case 3: 14 systems -> 2 pages, "Page 1 of 2"
ConfiguredSystem = generateTestSystems(14)
s.MenuPage = 0
if content, ok := findRow22(buildMainMenuScreen(s)); !ok || content != "Page 1 of 2" {
t.Fatalf("Expected 'Page 1 of 2' for 14 systems, got '%s' (found=%v)", content, ok)
}
// Case 4: Negative MenuPage clamped to 0
s.MenuPage = -99
if content, ok := findRow22(buildMainMenuScreen(s)); !ok || content != "Page 1 of 2" {
t.Fatalf("Negative MenuPage should clamp to Page 1 of 2, got '%s' (found=%v)", content, ok)
}
// Case 5: Out of bounds MenuPage clamped to last page
s.MenuPage = 99
if content, ok := findRow22(buildMainMenuScreen(s)); !ok || content != "Page 2 of 2" {
t.Fatalf("Out-of-bounds MenuPage should clamp to Page 2 of 2, got '%s' (found=%v)", content, ok)
}
}
func TestRunMenuLoopPF7PF8Pagination(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "MENU",
PromptSelect: "Selection ===>",
Footer: "Footer",
},
}
ConfiguredSystem = generateTestSystems(99) // 8 pages
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
ClientConn: clientServer,
ActiveSlotIdx: -1,
IsDisconnected: false,
MenuPage: 0,
}
done := make(chan struct{})
go func() {
runMenuLoop(s, clientServer)
close(done)
}()
// Helper to read a screen sent by server and respond with an AID key
sendAID := func(aid go3270.AID) {
buf := make([]byte, 4096)
_ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := clientUser.Read(buf)
if err != nil {
t.Fatalf("Failed to read screen from server: %v", err)
}
// Send 3270 response packet: [AID, CursorRowByte, CursorColByte, IAC, EOR]
_, err = clientUser.Write([]byte{byte(aid), 0x40, 0x40, IAC, EOR})
if err != nil {
t.Fatalf("Failed to write AID response: %v", err)
}
time.Sleep(50 * time.Millisecond)
}
// 1. Initial page is 0
if s.MenuPage != 0 {
t.Fatalf("Expected initial MenuPage 0, got %d", s.MenuPage)
}
// 2. Press PF7 on page 0: should stay at page 0
sendAID(go3270.AIDPF7)
s.Mu.Lock()
p := s.MenuPage
s.Mu.Unlock()
if p != 0 {
t.Fatalf("Expected MenuPage to stay 0 after PF7 on page 0, got %d", p)
}
// 3. Press PF8: should advance to page 1
sendAID(go3270.AIDPF8)
s.Mu.Lock()
p = s.MenuPage
s.Mu.Unlock()
if p != 1 {
t.Fatalf("Expected MenuPage 1 after PF8, got %d", p)
}
// 4. Press PF20 (alternate PF8): should advance to page 2
sendAID(go3270.AIDPF20)
s.Mu.Lock()
p = s.MenuPage
s.Mu.Unlock()
if p != 2 {
t.Fatalf("Expected MenuPage 2 after PF20, got %d", p)
}
// 5. Advance to last page (page 7)
for i := 2; i < 7; i++ {
sendAID(go3270.AIDPF8)
}
s.Mu.Lock()
p = s.MenuPage
s.Mu.Unlock()
if p != 7 {
t.Fatalf("Expected MenuPage 7 (last page), got %d", p)
}
// 6. Press PF8 on last page: should stay on page 7
sendAID(go3270.AIDPF8)
s.Mu.Lock()
p = s.MenuPage
s.Mu.Unlock()
if p != 7 {
t.Fatalf("Expected MenuPage to stay 7 after PF8 on last page, got %d", p)
}
// 7. Press PF7: should go back to page 6
sendAID(go3270.AIDPF7)
s.Mu.Lock()
p = s.MenuPage
s.Mu.Unlock()
if p != 6 {
t.Fatalf("Expected MenuPage 6 after PF7, got %d", p)
}
// 8. Press PF19 (alternate PF7): should go back to page 5
sendAID(go3270.AIDPF19)
s.Mu.Lock()
p = s.MenuPage
s.Mu.Unlock()
if p != 5 {
t.Fatalf("Expected MenuPage 5 after PF19, got %d", p)
}
// 9. Send PF3 to disconnect cleanly
sendAID(go3270.AIDPF3)
// Read the disconnect notification sent by send3270Message so the write to net.Pipe unblocks
discBuf := make([]byte, 2048)
_, _ = clientUser.Read(discBuf)
select {
case <-done:
// Clean exit
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for runMenuLoop to exit on PF3")
}
}
func generateTestCertificate(t *testing.T) (certFile, keyFile string) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("Failed to generate private key: %v", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Test sessman3270"},
},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
tmpDir := t.TempDir()
certPath := filepath.Join(tmpDir, "cert.pem")
keyPath := filepath.Join(tmpDir, "key.pem")
certOut, err := os.Create(certPath)
if err != nil {
t.Fatalf("Failed to create cert file: %v", err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
t.Fatalf("Failed to write cert: %v", err)
}
keyOut, err := os.Create(keyPath)
if err != nil {
t.Fatalf("Failed to create key file: %v", err)
}
defer keyOut.Close()
privBytes, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("Failed to marshal key: %v", err)
}
if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes}); err != nil {
t.Fatalf("Failed to write key: %v", err)
}
return certPath, keyPath
}
func TestResolveTLSConfig(t *testing.T) {
tests := []struct {
name string
certFlag string
keyFlag string
cfgServer *ServerConfig
wantEnabled bool
wantCert string
wantKey string
wantErrSub string
}{
{
name: "Defaults - Plaintext",
certFlag: "",
keyFlag: "",
cfgServer: nil,
wantEnabled: false,
wantCert: "",
wantKey: "",
wantErrSub: "",
},
{
name: "-k given without -t triggers error and infers TLS requirement",
certFlag: "",
keyFlag: "/path/to/key.pem",
cfgServer: nil,
wantEnabled: false,
wantErrSub: "-t and -k require both of each other",
},
{
name: "-t given without -k triggers error",
certFlag: "/path/to/cert.pem",
keyFlag: "",
cfgServer: nil,
wantEnabled: false,
wantErrSub: "-t and -k require both of each other",
},
{
name: "-k and -t given infers TLS enabled",
certFlag: "/path/to/cert.pem",
keyFlag: "/path/to/key.pem",
cfgServer: nil,
wantEnabled: true,
wantCert: "/path/to/cert.pem",
wantKey: "/path/to/key.pem",
wantErrSub: "",
},
{
name: "ServerConfig fallback enables TLS when configured",
certFlag: "",
keyFlag: "",
cfgServer: &ServerConfig{TLS: true, CertFile: "/cfg/cert.pem", KeyFile: "/cfg/key.pem"},
wantEnabled: true,
wantCert: "/cfg/cert.pem",
wantKey: "/cfg/key.pem",
wantErrSub: "",
},
{
name: "CLI -k overrides config key and requires -t when config has no cert",
certFlag: "",
keyFlag: "/cli/key.pem",
cfgServer: &ServerConfig{TLS: true, KeyFile: "/cfg/key.pem"},
wantEnabled: false,
wantErrSub: "-t and -k require both of each other",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
enabled, cert, key, err := resolveTLSConfig(tt.certFlag, tt.keyFlag, tt.cfgServer)
if tt.wantErrSub != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.wantErrSub)
}
if !strings.Contains(err.Error(), tt.wantErrSub) {
t.Fatalf("Expected error containing '%s', got '%v'", tt.wantErrSub, err)
}
} else {
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if enabled != tt.wantEnabled {
t.Fatalf("enabled mismatch: got %v, want %v", enabled, tt.wantEnabled)
}
if cert != tt.wantCert {
t.Fatalf("cert mismatch: got '%s', want '%s'", cert, tt.wantCert)
}
if key != tt.wantKey {
t.Fatalf("key mismatch: got '%s', want '%s'", key, tt.wantKey)
}
}
})
}
}
func TestTLSServerListenerAndClientConnection(t *testing.T) {
certFile, keyFile := generateTestCertificate(t)
ln, err := startServerListener("127.0.0.1:0", true, certFile, keyFile)
if err != nil {
t.Fatalf("Failed to start TLS listener: %v", err)
}
defer ln.Close()
addr := ln.Addr().String()
acceptedChan := make(chan net.Conn, 1)
go func() {
conn, err := ln.Accept()
if err == nil {
acceptedChan <- conn
if tc, ok := conn.(*tls.Conn); ok {
_ = tc.Handshake()
}
}
}()
tlsConf := &tls.Config{
InsecureSkipVerify: true,
}
clientConn, err := tls.Dial("tcp", addr, tlsConf)
if err != nil {
t.Fatalf("TLS client connection failed: %v", err)
}
defer clientConn.Close()
select {
case srvConn := <-acceptedChan:
defer srvConn.Close()
msg := []byte("PING")
if _, err := srvConn.Write(msg); err != nil {
t.Fatalf("Failed to write to client: %v", err)
}
recvBuf := make([]byte, 4)
if _, err := io.ReadFull(clientConn, recvBuf); err != nil {
t.Fatalf("Client failed to read: %v", err)
}
if string(recvBuf) != "PING" {
t.Fatalf("Expected 'PING', got '%s'", string(recvBuf))
}
case <-time.After(3 * time.Second):
t.Fatal("Timeout waiting for TLS connection accept")
}
}
func TestTLSListenerPlaintextClientHandshakeFailure(t *testing.T) {
certFile, keyFile := generateTestCertificate(t)
ln, err := startServerListener("127.0.0.1:0", true, certFile, keyFile)
if err != nil {
t.Fatalf("Failed to start TLS listener: %v", err)
}
defer ln.Close()
addr := ln.Addr().String()
handshakeErrChan := make(chan error, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
tlsConn, ok := conn.(*tls.Conn)
if !ok {
return
}
_ = tlsConn.SetDeadline(time.Now().Add(500 * time.Millisecond))
handshakeErrChan <- tlsConn.Handshake()
}()
plainConn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
defer plainConn.Close()
_, _ = plainConn.Write([]byte("NOT_A_TLS_HANDSHAKE"))
select {
case err := <-handshakeErrChan:
if err == nil {
t.Fatalf("Expected TLS handshake error for plaintext client, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for handshake failure")
}
}
func TestAuthenticateUser(t *testing.T) {
origGlobalConfig := GlobalConfig
defer func() { GlobalConfig = origGlobalConfig }()
// 1. When GlobalConfig is nil or Users slice is empty, any login is permitted
GlobalConfig = nil
if res := authenticateAndAuthorize("anyone", "anypass"); !res.OK {
t.Fatalf("Expected true when GlobalConfig is nil")
}
GlobalConfig = &Config{Users: nil}
if res := authenticateAndAuthorize("anyone", "anypass"); !res.OK {
t.Fatalf("Expected true when Users list is nil")
}
GlobalConfig = &Config{Users: []UserConfig{}}
if res := authenticateAndAuthorize("anyone", "anypass"); !res.OK {
t.Fatalf("Expected true when Users list is empty")
}
// 2. Generate bcrypt hash for "secret123"
hash, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("Failed to generate test hash: %v", err)
}
GlobalConfig = &Config{
Users: []UserConfig{
{Username: "rudi", Hash: string(hash)},
{Username: "admin", Password: string(hash)},
},
}
// Valid user and correct password
if res := authenticateAndAuthorize("rudi", "secret123"); !res.OK {
t.Fatalf("Expected successful auth for rudi, got reason: %s", res.Reason)
}
// Whitespace trimming preserves exact case
if res := authenticateAndAuthorize(" rudi ", "secret123"); !res.OK {
t.Fatalf("Expected whitespace-trimmed match for rudi, got reason: %s", res.Reason)
}
// Case-sensitivity: "RUDI" is a distinct user from "rudi"
if res := authenticateAndAuthorize("RUDI", "secret123"); res.OK {
t.Fatalf("Expected auth failure for uppercase RUDI when only lowercase rudi is configured")
}
// Password alias check
if res := authenticateAndAuthorize("admin", "secret123"); !res.OK {
t.Fatalf("Expected successful auth for admin (password alias), got reason: %s", res.Reason)
}
// Invalid password
if res := authenticateAndAuthorize("rudi", "wrongpass"); res.OK {
t.Fatalf("Expected auth failure for incorrect password")
}
// Unauthorized user
if res := authenticateAndAuthorize("unknownuser", "secret123"); res.OK {
t.Fatalf("Expected auth failure for unauthorized user")
} else if !strings.Contains(res.Reason, "not authorized") {
t.Fatalf("Expected reason to mention 'not authorized', got: %s", res.Reason)
}
}
func TestPasswordGenerationBcrypt(t *testing.T) {
password := "myTN3270Password!#"
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("Failed to generate bcrypt hash: %v", err)
}
if !strings.HasPrefix(string(hash), "$2a$") {
t.Fatalf("Expected bcrypt hash prefix $2a$, got: %s", string(hash))
}
if err := bcrypt.CompareHashAndPassword(hash, []byte(password)); err != nil {
t.Fatalf("CompareHashAndPassword failed: %v", err)
}
if err := bcrypt.CompareHashAndPassword(hash, []byte("wrong")); err == nil {
t.Fatalf("Expected hash comparison failure for wrong password")
}
}
func TestSelectionInputLength(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "TEST MENU",
PromptSelect: "Selection ===>",
Footer: "Test Footer",
},
}
ConfiguredSystem = []TargetSystem{{Name: "SYS1"}}
s := &UserMasterSession{
Username: "ADMIN",
DisplayTag: "12345678",
}
screen := buildMainMenuScreen(s)
var optField *go3270.Field
var stopField *go3270.Field
for i := range screen {
f := &screen[i]
if f.Row == 21 && f.Name == "opt" {
optField = f
} else if f.Row == 21 && optField != nil && f.Col > optField.Col {
stopField = f
break
}
}
if optField == nil {
t.Fatal("Expected 'opt' field on Row 21")
}
if stopField == nil {
t.Fatal("Expected stop field on Row 21 after 'opt'")
}
writableLen := stopField.Col - optField.Col - 1
if writableLen < 40 {
t.Fatalf("Selection input field is too short (%d characters). Expected at least 40 characters for admin commands", writableLen)
}
}
func TestUserAdminConfiguration(t *testing.T) {
tomlData := `
[[users]]
username = "sysadmin"
password = "adminpass"
admin = true
[[users]]
username = "operator"
password = "userpass"
admin = false
`
var cfg Config
if _, err := toml.Decode(tomlData, &cfg); err != nil {
t.Fatalf("Failed to decode TOML: %v", err)
}
if len(cfg.Users) != 2 {
t.Fatalf("Expected 2 users, got %d", len(cfg.Users))
}
if !cfg.Users[0].Admin {
t.Fatalf("Expected sysadmin admin=true, got %v", cfg.Users[0].Admin)
}
if cfg.Users[1].Admin {
t.Fatalf("Expected operator admin=false, got %v", cfg.Users[1].Admin)
}
origGlobalConfig := GlobalConfig
defer func() { GlobalConfig = origGlobalConfig }()
GlobalConfig = &cfg
res := authenticateAndAuthorize("sysadmin", "adminpass")
if !res.OK || !res.IsAdmin {
t.Fatalf("Expected sysadmin to be (OK=true, admin=true), got %+v", res)
}
res = authenticateAndAuthorize("operator", "userpass")
if !res.OK || res.IsAdmin {
t.Fatalf("Expected operator to be (OK=true, admin=false), got %+v", res)
}
res = authenticateAndAuthorize("sysadmin", "wrongpass")
if res.OK || res.IsAdmin {
t.Fatalf("Expected sysadmin with wrong password to be (OK=false, admin=false), got %+v", res)
}
res = authenticateAndAuthorize("unknown", "any")
if res.OK || res.IsAdmin {
t.Fatalf("Expected unknown user to be (OK=false, admin=false), got %+v", res)
}
}
func TestPublicFlagConfiguration(t *testing.T) {
origGlobalConfig := GlobalConfig
origFlag := publicFlag
defer func() {
GlobalConfig = origGlobalConfig
publicFlag = origFlag
}()
// 1. Default when not set
publicFlag = false
GlobalConfig = &Config{}
if isPublic() {
t.Fatal("Expected isPublic() to be false by default")
}
// 2. Top-level public = true in Config
GlobalConfig = &Config{Public: true}
if !isPublic() {
t.Fatal("Expected isPublic() to be true when Config.Public is true")
}
// 3. ServerConfig public = true in Config
GlobalConfig = &Config{Server: ServerConfig{Public: true}}
if !isPublic() {
t.Fatal("Expected isPublic() to be true when Config.Server.Public is true")
}
// 4. CLI flag -public / -P
GlobalConfig = &Config{}
publicFlag = true
if !isPublic() {
t.Fatal("Expected isPublic() to be true when publicFlag is true")
}
publicFlag = false
// 5. TOML parsing of public flag
tomlStrTop := `
public = true
[[systems]]
name = "SYS1"
`
var cfgTop Config
if _, err := toml.Decode(tomlStrTop, &cfgTop); err != nil {
t.Fatalf("Failed to decode top-level public TOML: %v", err)
}
if !cfgTop.Public {
t.Fatal("Expected cfgTop.Public to be true")
}
tomlStrServer := `
[server]
public = true
[[systems]]
name = "SYS1"
`
var cfgServer Config
if _, err := toml.Decode(tomlStrServer, &cfgServer); err != nil {
t.Fatalf("Failed to decode server-level public TOML: %v", err)
}
if !cfgServer.Server.Public {
t.Fatal("Expected cfgServer.Server.Public to be true")
}
}
func TestPublicModeAuthentication(t *testing.T) {
origGlobalConfig := GlobalConfig
origFlag := publicFlag
defer func() {
GlobalConfig = origGlobalConfig
publicFlag = origFlag
}()
publicFlag = false
hashAdmin, _ := bcrypt.GenerateFromPassword([]byte("adminpass"), bcrypt.DefaultCost)
hashUser, _ := bcrypt.GenerateFromPassword([]byte("userpass"), bcrypt.DefaultCost)
baseConfig := &Config{
Users: []UserConfig{
{Username: "admin", Hash: string(hashAdmin), Admin: true},
{Username: "user", Hash: string(hashUser), Admin: false},
},
}
// --- Test without public mode (public = false) ---
GlobalConfig = baseConfig
GlobalConfig.Public = false
// Correct admin credentials -> OK
res := authenticateAndAuthorize("admin", "adminpass")
if !res.OK || !res.IsAdmin {
t.Fatalf("Expected admin to have OK=true, isAdmin=true; got %+v", res)
}
// Correct user credentials -> OK
res = authenticateAndAuthorize("user", "userpass")
if !res.OK || res.IsAdmin {
t.Fatalf("Expected regular user to have OK=true, isAdmin=false; got %+v", res)
}
// Password mismatch -> REJECTED
if res := authenticateAndAuthorize("admin", "wrongpass"); res.OK {
t.Fatal("Expected auth failure for admin with wrong password when public=false")
}
// Unknown user -> REJECTED
if res := authenticateAndAuthorize("guest", "pass"); res.OK {
t.Fatal("Expected auth failure for unknown user when public=false")
}
// --- Test with public mode (public = true) ---
GlobalConfig.Public = true
// Correct admin credentials -> OK and admin=true
res = authenticateAndAuthorize("admin", "adminpass")
if !res.OK || !res.IsAdmin {
t.Fatalf("Expected admin auth to succeed with admin privileges when public=true; got %+v", res)
}
// Correct user credentials -> OK and admin=false
res = authenticateAndAuthorize("user", "userpass")
if !res.OK || res.IsAdmin {
t.Fatalf("Expected regular user auth to succeed without admin privileges when public=true; got %+v", res)
}
// Password mismatch in public mode -> ALLOWED, but NOT admin
res = authenticateAndAuthorize("admin", "wrongpass")
if !res.OK || res.IsAdmin {
t.Fatalf("Expected admin with wrong password in public mode to have OK=true, IsAdmin=false; got %+v", res)
}
// Unknown user in public mode -> ALLOWED, but NOT admin
res = authenticateAndAuthorize("guest", "pass")
if !res.OK || res.IsAdmin {
t.Fatalf("Expected unknown user in public mode to have OK=true, IsAdmin=false; got %+v", res)
}
}
func TestAdminKillCommand(t *testing.T) {
registryMu.Lock()
userSessions = make(map[string]*UserMasterSession)
registryMu.Unlock()
adminPipeServer, adminPipeClient := net.Pipe()
defer adminPipeServer.Close()
defer adminPipeClient.Close()
user1PipeServer, user1PipeClient := net.Pipe()
defer user1PipeServer.Close()
defer user1PipeClient.Close()
user2PipeServer, user2PipeClient := net.Pipe()
defer user2PipeServer.Close()
defer user2PipeClient.Close()
upServer1, upClient1 := net.Pipe()
defer upServer1.Close()
defer upClient1.Close()
adminSess := &UserMasterSession{
Username: "admin",
SessionKey: "key_admin_12345678",
DisplayTag: "adm12345",
ClientConn: adminPipeServer,
IsAdmin: true,
}
user1Sess := &UserMasterSession{
Username: "victim1",
SessionKey: "key_user1_87654321",
DisplayTag: "usr87654",
ClientConn: user1PipeServer,
Slots: []*SessionSlot{
{System: TargetSystem{Name: "SYS1"}, Upstream: upClient1},
},
IsAdmin: false,
}
user2Sess := &UserMasterSession{
Username: "victim2",
SessionKey: "key_user2_abcdef12",
DisplayTag: "usrabcde",
ClientConn: user2PipeServer,
IsAdmin: false,
}
registryMu.Lock()
userSessions[adminSess.SessionKey] = adminSess
userSessions[user1Sess.SessionKey] = user1Sess
userSessions[user2Sess.SessionKey] = user2Sess
registryMu.Unlock()
origNoticeSleep := adminNoticeSleep
adminNoticeSleep = 10 * time.Millisecond
defer func() { adminNoticeSleep = origNoticeSleep }()
// Drain messages on pipe connections in background to prevent pipe block
drain := func(c net.Conn) {
go func() {
buf := make([]byte, 1024)
for {
if _, err := c.Read(buf); err != nil {
return
}
}
}()
}
drain(adminPipeClient)
drain(user1PipeClient)
drain(user2PipeClient)
upReadDone := make(chan []byte, 1)
go func() {
data, _ := io.ReadAll(upServer1)
upReadDone <- data
}()
// 1. Admin kills user1 by DisplayTag
killedSelf := handleAdminKill(adminSess, "usr87654")
if killedSelf {
t.Fatal("Expected handleAdminKill to return false when killing another session")
}
// Verify user1 session resources terminated
registryMu.Lock()
_, existsUser1 := userSessions[user1Sess.SessionKey]
registryMu.Unlock()
if existsUser1 {
t.Fatal("Expected user1 to be deleted from registry")
}
user1Sess.Mu.Lock()
if !user1Sess.IsDisconnected {
t.Fatal("Expected user1 to be marked disconnected")
}
if user1Sess.Slots != nil {
t.Fatal("Expected user1 slots to be cleared")
}
user1Sess.Mu.Unlock()
// Verify upstream was cleanly unwound and closed
select {
case data := <-upReadDone:
if len(data) == 0 {
t.Fatal("Expected clean unwinding bytes on upstream before closure")
}
case <-time.After(1 * time.Second):
t.Fatal("Timeout waiting for upstream closure")
}
// 2. Admin kills user2 by #DisplayTag
killedSelf = handleAdminKill(adminSess, "#usrabcde")
if killedSelf {
t.Fatal("Expected handleAdminKill to return false when killing another session")
}
registryMu.Lock()
_, existsUser2 := userSessions[user2Sess.SessionKey]
registryMu.Unlock()
if existsUser2 {
t.Fatal("Expected user2 to be deleted from registry")
}
// 3. Admin attempts to kill non-existent session
killedSelf = handleAdminKill(adminSess, "nonexistent")
if killedSelf {
t.Fatal("Expected false for non-existent session")
}
// 4. Admin kills own session
killedSelf = handleAdminKill(adminSess, "adm12345")
if !killedSelf {
t.Fatal("Expected true when admin kills their own session")
}
registryMu.Lock()
_, existsAdmin := userSessions[adminSess.SessionKey]
registryMu.Unlock()
if existsAdmin {
t.Fatal("Expected admin session to be deleted after self-kill")
}
}
func TestAdminKillAllCommand(t *testing.T) {
registryMu.Lock()
userSessions = make(map[string]*UserMasterSession)
registryMu.Unlock()
adminPipeServer, adminPipeClient := net.Pipe()
defer adminPipeServer.Close()
defer adminPipeClient.Close()
userPipeServer, userPipeClient := net.Pipe()
defer userPipeServer.Close()
defer userPipeClient.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
adminSess := &UserMasterSession{
Username: "admin",
SessionKey: "key_admin",
DisplayTag: "adm00001",
ClientConn: adminPipeServer,
IsAdmin: true,
}
userSess := &UserMasterSession{
Username: "user",
SessionKey: "key_user",
DisplayTag: "usr00001",
ClientConn: userPipeServer,
Slots: []*SessionSlot{
{System: TargetSystem{Name: "SYS1"}, Upstream: upClient},
},
IsAdmin: false,
}
registryMu.Lock()
userSessions[adminSess.SessionKey] = adminSess
userSessions[userSess.SessionKey] = userSess
registryMu.Unlock()
origNoticeSleep := adminNoticeSleep
adminNoticeSleep = 10 * time.Millisecond
defer func() { adminNoticeSleep = origNoticeSleep }()
// Drain messages and upstream
go func() {
buf := make([]byte, 1024)
for {
if _, err := adminPipeClient.Read(buf); err != nil {
return
}
}
}()
go func() {
buf := make([]byte, 1024)
for {
if _, err := userPipeClient.Read(buf); err != nil {
return
}
}
}()
go func() {
_, _ = io.ReadAll(upServer)
}()
handleAdminKillAll(adminSess)
registryMu.Lock()
count := len(userSessions)
registryMu.Unlock()
if count != 0 {
t.Fatalf("Expected all sessions to be cleared, found %d", count)
}
userSess.Mu.Lock()
if !userSess.IsDisconnected {
t.Fatal("Expected user session to be marked disconnected")
}
if userSess.Slots != nil {
t.Fatal("Expected user slots to be nil")
}
userSess.Mu.Unlock()
adminSess.Mu.Lock()
if !adminSess.IsDisconnected {
t.Fatal("Expected admin session to be marked disconnected (including this one)")
}
adminSess.Mu.Unlock()
}
func TestAdminByeCommand(t *testing.T) {
registryMu.Lock()
userSessions = make(map[string]*UserMasterSession)
registryMu.Unlock()
adminPipeServer, adminPipeClient := net.Pipe()
defer adminPipeServer.Close()
defer adminPipeClient.Close()
dummyListener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create dummy listener: %v", err)
}
serverListener = dummyListener
var shutdownInvoked bool
origShutdown := shutdownFunc
defer func() { shutdownFunc = origShutdown }()
shutdownFunc = func() {
shutdownInvoked = true
}
adminSess := &UserMasterSession{
Username: "admin",
SessionKey: "key_admin_bye",
DisplayTag: "admbye01",
ClientConn: adminPipeServer,
IsAdmin: true,
}
registryMu.Lock()
userSessions[adminSess.SessionKey] = adminSess
registryMu.Unlock()
origNoticeSleep := adminNoticeSleep
adminNoticeSleep = 10 * time.Millisecond
defer func() { adminNoticeSleep = origNoticeSleep }()
go func() {
buf := make([]byte, 1024)
for {
_ = adminPipeClient.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
if _, err := adminPipeClient.Read(buf); err != nil {
return
}
}
}()
handleAdminBye(adminSess)
if !shutdownInvoked {
t.Fatal("Expected shutdownFunc to be invoked on 'bye' command")
}
// Verify listener was closed
if _, err := dummyListener.Accept(); err == nil {
t.Fatal("Expected listener to be closed")
}
registryMu.Lock()
count := len(userSessions)
registryMu.Unlock()
if count != 0 {
t.Fatalf("Expected all sessions cleared on bye, found %d", count)
}
}
func TestNonAdminCannotExecuteAdminCommands(t *testing.T) {
origSystems := ConfiguredSystem
defer func() { ConfiguredSystem = origSystems }()
ConfiguredSystem = []TargetSystem{{Name: "SYS1"}}
// A non-admin session
nonAdminSess := &UserMasterSession{
Username: "regularuser",
DisplayTag: "reg12345",
IsAdmin: false,
}
adminSess := &UserMasterSession{
Username: "admin",
SessionKey: "adminkey",
DisplayTag: "adm54321",
IsAdmin: true,
}
registryMu.Lock()
userSessions = make(map[string]*UserMasterSession)
userSessions["adminkey"] = adminSess
registryMu.Unlock()
// If a non-admin enters "kill adm54321", "bye", or "killall",
// the optStr is not recognized as a number, and admin commands are only checked if s.IsAdmin is true.
adminCmds := []string{"bye", "BYE", "killall", "KILLALL", "kill adm54321", "KILL adm54321"}
for _, cmd := range adminCmds {
// Verify that strconv.Atoi fails for these admin commands
_, err := strconv.Atoi(cmd)
if err == nil {
t.Fatalf("Admin command '%s' should not parse as integer option", cmd)
}
// And verify nonAdminSess.IsAdmin is false
if nonAdminSess.IsAdmin {
t.Fatalf("Expected non-admin session to have IsAdmin=false")
}
}
// Verify admin session is still intact
registryMu.Lock()
_, adminStillExists := userSessions["adminkey"]
registryMu.Unlock()
if !adminStillExists {
t.Fatal("Admin session was improperly deleted")
}
}
func TestMainMenuRowColors(t *testing.T) {
origSystems := ConfiguredSystem
defer func() {
ConfiguredSystem = origSystems
}()
ConfiguredSystem = []TargetSystem{
{Name: "SYS_INACTIVE", Description: "Inactive system"},
{Name: "SYS_ACTIVE", Description: "Active system"},
{Name: "SYS_NOTICE", Description: "Notice system"},
}
slotActive := &SessionSlot{
System: ConfiguredSystem[1],
ScreenChanged: false,
}
slotNotice := &SessionSlot{
System: ConfiguredSystem[2],
ScreenChanged: true,
}
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
Slots: []*SessionSlot{
nil, // idx 0: INACTIVE
slotActive, // idx 1: ACTIVE
slotNotice, // idx 2: NOTICE
},
}
screen := buildMainMenuScreen(s)
// Rows are 7, 8, 9
var rowInactive, rowActive, rowNotice *go3270.Field
for i := range screen {
f := &screen[i]
if f.Row == 7 {
rowInactive = f
} else if f.Row == 8 {
rowActive = f
} else if f.Row == 9 {
rowNotice = f
}
}
if rowInactive == nil || rowActive == nil || rowNotice == nil {
t.Fatalf("Expected fields for rows 7, 8, and 9")
}
// Verify INACTIVE: default color, not intense
if !strings.Contains(rowInactive.Content, "INACTIVE") {
t.Errorf("Expected row 7 to contain INACTIVE, got: %s", rowInactive.Content)
}
if rowInactive.Color != go3270.DefaultColor {
t.Errorf("Expected INACTIVE row to have DefaultColor, got: %v", rowInactive.Color)
}
if rowInactive.Intense {
t.Errorf("Expected INACTIVE row to not be Intense")
}
// Verify ACTIVE: Yellow color, not intense
if !strings.Contains(rowActive.Content, "ACTIVE") {
t.Errorf("Expected row 8 to contain ACTIVE, got: %s", rowActive.Content)
}
if rowActive.Color != go3270.Yellow {
t.Errorf("Expected ACTIVE row to have Yellow color (%v), got: %v", go3270.Yellow, rowActive.Color)
}
if rowActive.Intense {
t.Errorf("Expected ACTIVE row to not be Intense")
}
// Verify NOTICE: default color, intense
if !strings.Contains(rowNotice.Content, "NOTICE") {
t.Errorf("Expected row 9 to contain NOTICE, got: %s", rowNotice.Content)
}
if rowNotice.Color != go3270.DefaultColor {
t.Errorf("Expected NOTICE row to have DefaultColor, got: %v", rowNotice.Color)
}
if !rowNotice.Intense {
t.Errorf("Expected NOTICE row to be Intense")
}
}
// TestResumeTakeoverFromAnotherTerminalOnMenu verifies that when a new terminal
// connects and takes over a session whose previous terminal was on the menu:
// 1. Closing the old terminal socket unblocks the old menu loop cleanly.
// 2. The old terminal does NOT set IsDisconnected = true on the master session.
// 3. When the new terminal resumes a slot, upstream updates are actively forwarded
// to the new client connection rather than dropped (which previously caused
// the 3270 SYSTEM X keyboard lock bug).
func TestResumeTakeoverFromAnotherTerminalOnMenu(t *testing.T) {
clientServer1, clientUser1 := net.Pipe()
defer clientServer1.Close()
defer clientUser1.Close()
clientServer2, clientUser2 := net.Pipe()
defer clientServer2.Close()
defer clientUser2.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
slot := &SessionSlot{
System: TargetSystem{Name: "TESTPUBVM"},
Upstream: upClient,
LastScreen: []byte{CMD_ERASE_WRITE, 0xC3, 'I', 'N', 'I', 'T', IAC, EOR},
}
userSession := &UserMasterSession{
Username: "RUDI",
SessionKey: "testkey123",
DisplayTag: "c70c9bec",
TerminalType: "IBM-3279-4-E",
ClientConn: clientServer1,
ActiveSlotIdx: -1,
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(userSession, slot, 0)
// Terminal 1 is running runMenuLoop
term1Done := make(chan struct{})
go func() {
runMenuLoop(userSession, clientServer1)
close(term1Done)
}()
// Terminal 1 reads menu screen
menuBuf := make([]byte, 4096)
_ = clientUser1.SetReadDeadline(time.Now().Add(1 * time.Second))
_, err := clientUser1.Read(menuBuf)
if err != nil {
t.Fatalf("Terminal 1 failed to read initial menu: %v", err)
}
// Now Terminal 2 connects and takes over the session
userSession.Mu.Lock()
oldConn := userSession.ClientConn
userSession.ClientConn = clientServer2
userSession.IsDisconnected = false
userSession.ActiveSlotIdx = -1
userSession.Mu.Unlock()
// Server closes previous client connection socket
_ = oldConn.Close()
// Wait for Terminal 1's goroutine to cleanly terminate
select {
case <-term1Done:
// Clean exit
case <-time.After(2 * time.Second):
t.Fatal("Terminal 1 goroutine did not exit after oldConn was closed")
}
// Verify that Terminal 1 exiting did NOT mark the session as disconnected!
userSession.Mu.Lock()
isDisc := userSession.IsDisconnected
userSession.Mu.Unlock()
if isDisc {
t.Fatal("Regression: Terminal 1 exit marked userSession.IsDisconnected = true!")
}
// Terminal 2 resumes slot 0
term2BridgeDone := make(chan struct{})
go func() {
connectOrResumeSlot(userSession, 0, clientServer2)
close(term2BridgeDone)
}()
// Terminal 2 receives buffered LastScreen from connectOrResumeSlot
lastScreenBuf := make([]byte, 4096)
_ = clientUser2.SetReadDeadline(time.Now().Add(1 * time.Second))
n, err := clientUser2.Read(lastScreenBuf)
if err != nil || n == 0 {
t.Fatalf("Terminal 2 failed to receive refreshed screen upon resume: %v", err)
}
// Upstream host sends an update while Terminal 2 is active
updateData := []byte{CMD_WRITE, 0xC3, 'L', 'I', 'V', 'E', IAC, EOR}
_, err = upServer.Write(updateData)
if err != nil {
t.Fatalf("Failed to write upstream update: %v", err)
}
// Terminal 2 MUST receive the update in real-time (not dropped/locked in SYSTEM X)
liveBuf := make([]byte, len(updateData))
_ = clientUser2.SetReadDeadline(time.Now().Add(1 * time.Second))
_, err = io.ReadFull(clientUser2, liveBuf)
if err != nil {
t.Fatalf("Terminal 2 did not receive real-time upstream data (SYSTEM X lock bug reproduced): %v", err)
}
if !bytes.Equal(liveBuf, updateData) {
t.Fatalf("Terminal 2 received corrupted data. Got: %x, Expected: %x", liveBuf, updateData)
}
// Terminal 2 sends PA3 to return to menu
_, _ = clientUser2.Write([]byte{byte(go3270.AIDPA3)})
select {
case <-term2BridgeDone:
// Bridge exited properly on PA3
case <-time.After(2 * time.Second):
t.Fatal("Terminal 2 bridge did not exit on PA3")
}
}
// TestResumeTakeoverFromAnotherTerminalInActiveSlot verifies that when a new terminal
// connects and takes over while the previous terminal was inside an active slot bridge:
// 1. Closing the old socket causes the old bridge to exit cleanly.
// 2. The old bridge does NOT set IsDisconnected = true or ActiveSlotIdx = -1 on the new connection.
// 3. The old bridge does NOT fall through to run the menu loop on the new connection.
// 4. The new terminal can resume the slot and receive upstream data without locks.
func TestResumeTakeoverFromAnotherTerminalInActiveSlot(t *testing.T) {
clientServer1, clientUser1 := net.Pipe()
defer clientServer1.Close()
defer clientUser1.Close()
clientServer2, clientUser2 := net.Pipe()
defer clientServer2.Close()
defer clientUser2.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
slot := &SessionSlot{
System: TargetSystem{Name: "TESTPUBVM"},
Upstream: upClient,
LastScreen: []byte{CMD_ERASE_WRITE, 0xC3, 'S', 'L', 'O', 'T', IAC, EOR},
}
userSession := &UserMasterSession{
Username: "RUDI",
SessionKey: "testkey456",
DisplayTag: "c70c9bec",
TerminalType: "IBM-3279-4-E",
ClientConn: clientServer1,
ActiveSlotIdx: 0,
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(userSession, slot, 0)
term1BridgeDone := make(chan struct{})
go func() {
bridgeClientToUpstream(userSession, slot, clientServer1)
close(term1BridgeDone)
}()
// Terminal 2 connects and takes over
userSession.Mu.Lock()
oldConn := userSession.ClientConn
userSession.ClientConn = clientServer2
userSession.IsDisconnected = false
userSession.ActiveSlotIdx = -1
userSession.Mu.Unlock()
// Close old socket
_ = oldConn.Close()
select {
case <-term1BridgeDone:
// Clean exit
case <-time.After(2 * time.Second):
t.Fatal("Terminal 1 bridge failed to exit after socket closed")
}
// Verify IsDisconnected was NOT corrupted
userSession.Mu.Lock()
if userSession.IsDisconnected {
t.Fatal("Regression: Terminal 1 exit marked userSession.IsDisconnected = true")
}
userSession.Mu.Unlock()
// Terminal 2 resumes slot
term2BridgeDone := make(chan struct{})
go func() {
connectOrResumeSlot(userSession, 0, clientServer2)
close(term2BridgeDone)
}()
// Read refreshed LastScreen
screenBuf := make([]byte, 4096)
_ = clientUser2.SetReadDeadline(time.Now().Add(1 * time.Second))
_, err := clientUser2.Read(screenBuf)
if err != nil {
t.Fatalf("Terminal 2 failed to read screen: %v", err)
}
// Upstream sends update
updateData := []byte{CMD_WRITE, 0xC3, 'A', 'F', 'T', 'E', 'R', IAC, EOR}
_, _ = upServer.Write(updateData)
recvBuf := make([]byte, len(updateData))
_ = clientUser2.SetReadDeadline(time.Now().Add(1 * time.Second))
_, err = io.ReadFull(clientUser2, recvBuf)
if err != nil {
t.Fatalf("Terminal 2 did not receive update after takeover: %v", err)
}
if !bytes.Equal(recvBuf, updateData) {
t.Fatalf("Terminal 2 received corrupted data: %x != %x", recvBuf, updateData)
}
// Clean up
_, _ = clientUser2.Write([]byte{byte(go3270.AIDPA3)})
select {
case <-term2BridgeDone:
case <-time.After(2 * time.Second):
t.Fatal("Terminal 2 bridge did not exit on PA3")
}
}
func TestAuthenticateAndAuthorizeUnified(t *testing.T) {
origConfig := GlobalConfig
origPub := publicFlag
defer func() {
GlobalConfig = origConfig
publicFlag = origPub
}()
publicFlag = false
adminHash, err := bcrypt.GenerateFromPassword([]byte("adminpass"), bcrypt.MinCost)
if err != nil {
t.Fatalf("Failed to hash admin pass: %v", err)
}
userHash, err := bcrypt.GenerateFromPassword([]byte("userpass"), bcrypt.MinCost)
if err != nil {
t.Fatalf("Failed to hash user pass: %v", err)
}
GlobalConfig = &Config{
Users: []UserConfig{
{Username: "admin", Hash: string(adminHash), Admin: true},
{Username: "regular", Hash: string(userHash), Admin: false},
},
}
// 1. Admin login with correct password
res := authenticateAndAuthorize("admin", "adminpass")
if !res.OK || !res.IsAdmin {
t.Fatalf("Expected admin to be OK, Admin=true; got %+v", res)
}
// 2. Regular user login with correct password
res = authenticateAndAuthorize("regular", "userpass")
if !res.OK || res.IsAdmin {
t.Fatalf("Expected regular user to be OK, Admin=false; got %+v", res)
}
// 3. Regular user with wrong password
res = authenticateAndAuthorize("regular", "wrongpass")
if res.OK || res.IsAdmin {
t.Fatalf("Expected wrong password to fail; got %+v", res)
}
// 4. Unknown user in non-public mode
res = authenticateAndAuthorize("stranger", "anypass")
if res.OK {
t.Fatalf("Expected stranger to fail; got %+v", res)
}
// 5. Unknown user in public mode
publicFlag = true
res = authenticateAndAuthorize("stranger", "anypass")
if !res.OK || res.IsAdmin {
t.Fatalf("Expected stranger in public mode to be OK, Admin=false; got %+v", res)
}
}
func TestCMDFieldScreenLayoutAndLength(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "TEST MENU",
PromptSelect: "Selection ===>",
Footer: "Test Footer",
},
}
ConfiguredSystem = []TargetSystem{
{Name: "SYS1", Description: "System 1"},
{Name: "SYS2", Description: "System 2"},
}
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
}
screen := buildMainMenuScreen(s)
// 1. Verify headers on Row 5 and Row 6 have CMD before ID
var headerRow5, headerRow6 *go3270.Field
for i := range screen {
f := &screen[i]
if f.Row == 5 {
headerRow5 = f
} else if f.Row == 6 {
headerRow6 = f
}
}
if headerRow5 == nil || headerRow6 == nil {
t.Fatal("Expected header rows on Row 5 and Row 6")
}
cmdIdx5 := strings.Index(headerRow5.Content, "CMD")
idIdx5 := strings.Index(headerRow5.Content, "ID")
if cmdIdx5 == -1 || idIdx5 == -1 || cmdIdx5 >= idIdx5 {
t.Fatalf("Expected 'CMD' before 'ID' in Row 5 header, got '%s'", headerRow5.Content)
}
cmdIdx6 := strings.Index(headerRow6.Content, "---")
idIdx6 := strings.Index(headerRow6.Content, "----")
if cmdIdx6 == -1 || idIdx6 == -1 || cmdIdx6 >= idIdx6 {
t.Fatalf("Expected '---' before '----' in Row 6 underline, got '%s'", headerRow6.Content)
}
// 2. Verify that each system row has a 3-character writable CMD input field before ID
for sysIdx := 0; sysIdx < 2; sysIdx++ {
row := 7 + sysIdx
var cmdField, sysField *go3270.Field
expectedCmdName := fmt.Sprintf("cmd_%d", sysIdx+1)
for i := range screen {
f := &screen[i]
if f.Row == row && f.Name == expectedCmdName {
cmdField = f
} else if f.Row == row && cmdField != nil && f.Col > cmdField.Col {
sysField = f
break
}
}
if cmdField == nil {
t.Fatalf("Row %d: Expected writable CMD field '%s'", row, expectedCmdName)
}
if !cmdField.Write {
t.Fatalf("Row %d: CMD field must be writable (Write: true)", row)
}
if cmdField.Col != 2 {
t.Fatalf("Row %d: Expected CMD field at Col 2, got %d", row, cmdField.Col)
}
if sysField == nil {
t.Fatalf("Row %d: Expected system description field after CMD field", row)
}
if sysField.Write {
t.Fatalf("Row %d: System description field must be protected (Write: false)", row)
}
// In 3270, writable field length between cmdField and nextField (attribute byte)
writableLen := sysField.Col - cmdField.Col - 1
if writableLen != 3 {
t.Fatalf("Row %d: Expected CMD field writable length to be 3, got %d (CMD Col: %d, Sys Col: %d)",
row, writableLen, cmdField.Col, sysField.Col)
}
expectedPrefix := fmt.Sprintf("[%2d]", sysIdx+1)
if !strings.HasPrefix(sysField.Content, expectedPrefix) {
t.Fatalf("Row %d: Expected system field content to start with '%s', got '%s'",
row, expectedPrefix, sysField.Content)
}
}
}
func TestKillSlotFunction(t *testing.T) {
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
slot := &SessionSlot{
System: TargetSystem{Name: "TEST_SYSTEM"},
Upstream: upClient,
}
s := &UserMasterSession{
Username: "USER1",
DisplayTag: "12345678",
ActiveSlotIdx: 0,
Slots: []*SessionSlot{slot},
}
// Verify initial state
if s.CountActiveSlots() != 1 {
t.Fatalf("Expected 1 active slot, got %d", s.CountActiveSlots())
}
// Kill slot 0
killSlot(s, 0)
s.Mu.Lock()
if s.Slots[0] != nil {
t.Fatal("Expected s.Slots[0] to be nil after killSlot")
}
if s.ActiveSlotIdx != -1 {
t.Fatalf("Expected ActiveSlotIdx to be -1, got %d", s.ActiveSlotIdx)
}
s.Mu.Unlock()
if s.CountActiveSlots() != 0 {
t.Fatalf("Expected 0 active slots after killSlot, got %d", s.CountActiveSlots())
}
// Verify upstream was closed
buf := make([]byte, 10)
_ = upServer.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
_, err := upServer.Read(buf)
if err == nil {
t.Fatal("Expected read error on upstream server socket after client closed")
}
// Calling killSlot again on nil slot should be a safe no-op
killSlot(s, 0)
killSlot(s, 99)
}
func TestRunMenuLoopCMDFieldActions(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "MENU",
PromptSelect: "Selection ===>",
Footer: "Footer",
},
}
ConfiguredSystem = []TargetSystem{
{Name: "SYS1"},
{Name: "SYS2"},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer1, upClient1 := net.Pipe()
defer upServer1.Close()
defer upClient1.Close()
go func() { _, _ = io.ReadAll(upServer1) }()
slot1 := &SessionSlot{
System: ConfiguredSystem[0],
Upstream: upClient1,
}
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
ClientConn: clientServer,
ActiveSlotIdx: -1,
IsDisconnected: false,
MenuPage: 0,
Slots: []*SessionSlot{slot1, nil},
}
loopDone := make(chan struct{})
go func() {
runMenuLoop(s, clientServer)
close(loopDone)
}()
// Helper to read menu from server
readMenu := func() {
buf := make([]byte, 4096)
_ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := clientUser.Read(buf)
if err != nil {
t.Fatalf("Failed to read menu from server: %v", err)
}
}
// 1. Initial menu read: slot 1 is ACTIVE
readMenu()
if s.CountActiveSlots() != 1 {
t.Fatalf("Expected 1 active slot, got %d", s.CountActiveSlots())
}
// 2. Send 'K' in cmd_1 to kill slot 1
// 3270 response: AIDEnter (0x7D), Cursor (0x40, 0x40), SBA (0x11), Addr for (Row 7, Col 3 = 563) = 0xC8, 0xF3, 'K' (EBCDIC 0xD2), IAC, EOR
packetKill := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xD2, IAC, EOR}
_, err := clientUser.Write(packetKill)
if err != nil {
t.Fatalf("Failed to write packetKill: %v", err)
}
// Menu is re-rendered by loop
readMenu()
// Verify slot 1 was killed!
if s.CountActiveSlots() != 0 {
t.Fatalf("Expected slot 1 to be killed (0 active slots), got %d", s.CountActiveSlots())
}
s.Mu.Lock()
slot1Killed := s.Slots[0] == nil
s.Mu.Unlock()
if !slot1Killed {
t.Fatal("Expected s.Slots[0] to be nil after 'K' command")
}
// 3. Send lowercase 'k' in cmd_1 when already inactive -> safe no-op
// 'k' in EBCDIC 037 is 0x92
packetKillLower := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0x92, IAC, EOR}
_, err = clientUser.Write(packetKillLower)
if err != nil {
t.Fatalf("Failed to write packetKillLower: %v", err)
}
readMenu()
if s.CountActiveSlots() != 0 {
t.Fatalf("Expected 0 active slots, got %d", s.CountActiveSlots())
}
// 4. Send PF3 to disconnect cleanly
packetPF3 := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR}
_, _ = clientUser.Write(packetPF3)
discBuf := make([]byte, 2048)
_, _ = clientUser.Read(discBuf)
select {
case <-loopDone:
// Clean exit
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for runMenuLoop to exit")
}
}
func TestRunMenuLoopCMDNonKConnects(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "MENU",
PromptSelect: "Selection ===>",
Footer: "Footer",
},
}
ConfiguredSystem = []TargetSystem{
{Name: "SYS1"},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer1, upClient1 := net.Pipe()
defer upServer1.Close()
defer upClient1.Close()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 'H', 'I', IAC, EOR}
slot1 := &SessionSlot{
System: ConfiguredSystem[0],
Upstream: upClient1,
LastScreen: initialScreen,
}
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
ClientConn: clientServer,
ActiveSlotIdx: -1,
IsDisconnected: false,
MenuPage: 0,
Slots: []*SessionSlot{slot1},
}
loopDone := make(chan struct{})
go func() {
runMenuLoop(s, clientServer)
close(loopDone)
}()
// 1. Read initial menu
buf := make([]byte, 4096)
_ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := clientUser.Read(buf)
if err != nil {
t.Fatalf("Failed to read menu: %v", err)
}
// 2. Send 'S' (EBCDIC 0xE2) in cmd_1 -> should connect / resume slot 0
packetConnect := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xE2, IAC, EOR}
_, err = clientUser.Write(packetConnect)
if err != nil {
t.Fatalf("Failed to write packetConnect: %v", err)
}
// Client should now receive slot1's LastScreen from connectOrResumeSlot!
slotScreenBuf := make([]byte, 4096)
_ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second))
n, err := clientUser.Read(slotScreenBuf)
if err != nil || n == 0 {
t.Fatalf("Failed to read slot screen upon connect: %v", err)
}
if !bytes.Equal(slotScreenBuf[:n], initialScreen) {
t.Fatalf("Expected slot screen %x, got %x", initialScreen, slotScreenBuf[:n])
}
s.Mu.Lock()
activeIdx := s.ActiveSlotIdx
s.Mu.Unlock()
if activeIdx != 0 {
t.Fatalf("Expected ActiveSlotIdx to be 0 upon connect, got %d", activeIdx)
}
// Send PA3 to background session and return to menu
pa3Packet := []byte{byte(go3270.AIDPA3), 0x40, 0x40, IAC, EOR}
_, _ = clientUser.Write(pa3Packet)
// Read returning menu
_ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _ = clientUser.Read(buf)
// Send PF3 to disconnect
pf3Packet := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR}
_, _ = clientUser.Write(pf3Packet)
discBuf := make([]byte, 2048)
_, _ = clientUser.Read(discBuf)
select {
case <-loopDone:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for runMenuLoop to exit")
}
}
func TestCleanCloseUpstreamTN3270E(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
done := make(chan struct{})
var received []byte
go func() {
defer close(done)
received, _ = io.ReadAll(server)
}()
cleanCloseUpstream(client, true)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for server read")
}
expectedUNBIND := []byte{TN3270E_DATA_UNBIND, 0x00, 0x00, 0x00, 0x00, TN3270E_UNBIND_NORMAL, IAC, EOR}
expectedUnwind := []byte{
IAC, WONT, OPT_BINARY,
IAC, DONT, OPT_BINARY,
IAC, WONT, OPT_EOR,
IAC, DONT, OPT_EOR,
IAC, WONT, OPT_TN3270E,
IAC, DONT, OPT_TN3270E,
}
expectedTotal := append(append([]byte{}, expectedUNBIND...), expectedUnwind...)
if !bytes.Equal(received, expectedTotal) {
t.Fatalf("CleanCloseUpstream TN3270E payload mismatch.\nGot: %x\nExpected: %x", received, expectedTotal)
}
}
func TestCleanCloseUpstreamTN3270(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
done := make(chan struct{})
var received []byte
go func() {
defer close(done)
received, _ = io.ReadAll(server)
}()
cleanCloseUpstream(client, false)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for server read")
}
expectedUnwind := []byte{
IAC, WONT, OPT_BINARY,
IAC, DONT, OPT_BINARY,
IAC, WONT, OPT_EOR,
IAC, DONT, OPT_EOR,
}
if !bytes.Equal(received, expectedUnwind) {
t.Fatalf("CleanCloseUpstream TN3270 payload mismatch.\nGot: %x\nExpected: %x", received, expectedUnwind)
}
}
func TestTerminateSessionCleanClose(t *testing.T) {
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer1, upClient1 := net.Pipe()
defer upServer1.Close()
upServer2, upClient2 := net.Pipe()
defer upServer2.Close()
slot1 := &SessionSlot{
System: TargetSystem{Name: "SYS1"},
Upstream: upClient1,
IsTN3270E: true,
}
slot2 := &SessionSlot{
System: TargetSystem{Name: "SYS2"},
Upstream: upClient2,
IsTN3270E: false,
}
sess := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
ClientConn: clientServer,
Slots: []*SessionSlot{slot1, slot2},
}
done1 := make(chan []byte)
go func() {
data, _ := io.ReadAll(upServer1)
done1 <- data
}()
done2 := make(chan []byte)
go func() {
data, _ := io.ReadAll(upServer2)
done2 <- data
}()
clientDone := make(chan struct{})
go func() {
buf := make([]byte, 1024)
_, _ = clientUser.Read(buf)
close(clientDone)
}()
terminateSession(sess, "Session terminated by admin")
var data1, data2 []byte
select {
case data1 = <-done1:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for slot 1 clean close")
}
select {
case data2 = <-done2:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for slot 2 clean close")
}
// Slot 1 should have received UNBIND + unwinding
expectedUNBIND := []byte{TN3270E_DATA_UNBIND, 0x00, 0x00, 0x00, 0x00, TN3270E_UNBIND_NORMAL, IAC, EOR}
if !bytes.Contains(data1, expectedUNBIND) {
t.Fatalf("Slot 1 missing UNBIND: %x", data1)
}
if !bytes.Contains(data1, []byte{IAC, WONT, OPT_TN3270E}) {
t.Fatalf("Slot 1 missing WONT TN3270E: %x", data1)
}
// Slot 2 should have received basic telnet unwinding and NO UNBIND
if bytes.Contains(data2, expectedUNBIND) {
t.Fatalf("Slot 2 unexpectedly received UNBIND: %x", data2)
}
if !bytes.Contains(data2, []byte{IAC, WONT, OPT_BINARY}) {
t.Fatalf("Slot 2 missing WONT BINARY: %x", data2)
}
}
func TestNegotiateUpstreamTelnetTN3270E(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
serverDone := make(chan error, 1)
go func() {
defer close(serverDone)
buf := make([]byte, 1024)
// 1. Server initiates RFC 2355: sends IAC DO TN3270E
offer := []byte{IAC, DO, OPT_TN3270E}
if _, err := server.Write(offer); err != nil {
serverDone <- err
return
}
// 2. Read client's IAC WILL TN3270E
n, err := server.Read(buf)
if err != nil {
serverDone <- err
return
}
if !bytes.Contains(buf[:n], []byte{IAC, WILL, OPT_TN3270E}) {
serverDone <- fmt.Errorf("expected WILL TN3270E, got %x", buf[:n])
return
}
// 3. Server asks for DEVICE-TYPE
reply := []byte{
IAC, SB, OPT_TN3270E, TN3270E_SEND, TN3270E_DEVICE_TYPE, IAC, SE,
}
if _, err := server.Write(reply); err != nil {
serverDone <- err
return
}
// 4. Read client's DEVICE-TYPE REQUEST IBM-3278-2
n, err = server.Read(buf)
if err != nil {
serverDone <- err
return
}
if !bytes.Contains(buf[:n], []byte{IAC, SB, OPT_TN3270E, TN3270E_DEVICE_TYPE, TN3270E_REQUEST}) {
serverDone <- fmt.Errorf("expected DEVICE-TYPE REQUEST, got %x", buf[:n])
return
}
// 5. Server accepts DEVICE-TYPE IS IBM-3278-2
devIs := []byte{
IAC, SB, OPT_TN3270E, TN3270E_DEVICE_TYPE, TN3270E_IS,
'I', 'B', 'M', '-', '3', '2', '7', '8', '-', '2',
IAC, SE,
}
if _, err := server.Write(devIs); err != nil {
serverDone <- err
return
}
// 6. Read client's FUNCTIONS REQUEST RESPONSES BIND-IMAGE
n, err = server.Read(buf)
if err != nil {
serverDone <- err
return
}
if !bytes.Contains(buf[:n], []byte{IAC, SB, OPT_TN3270E, TN3270E_FUNCTIONS, TN3270E_REQUEST}) {
serverDone <- fmt.Errorf("expected FUNCTIONS REQUEST, got %x", buf[:n])
return
}
// 7. Server responds FUNCTIONS IS RESPONSES BIND-IMAGE and sends initial 3270 screen
screenPayload := []byte{
IAC, SB, OPT_TN3270E, TN3270E_FUNCTIONS, TN3270E_IS, TN3270E_FUNC_RESPONSES, TN3270E_FUNC_BIND_IMAGE, IAC, SE,
// TN3270E 5-byte header + 3270 data
TN3270E_DATA_3270, 0x00, 0x00, 0x00, 0x00,
CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'M', 'A', 'I', 'N',
IAC, EOR,
}
if _, err := server.Write(screenPayload); err != nil {
serverDone <- err
return
}
}()
initData, isTN3270E, _, err := negotiateUpstreamTelnet(client, "IBM-3278-2")
if err != nil {
t.Fatalf("negotiateUpstreamTelnet failed: %v", err)
}
if err := <-serverDone; err != nil {
t.Fatalf("mock server error: %v", err)
}
if !isTN3270E {
t.Fatal("expected isTN3270E == true")
}
expectedStripped := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'M', 'A', 'I', 'N', IAC, EOR}
if !bytes.Equal(initData, expectedStripped) {
t.Fatalf("expected initial screen %x, got %x", expectedStripped, initData)
}
}
func TestNegotiateUpstreamTelnetTN3270Fallback(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
serverDone := make(chan error, 1)
go func() {
defer close(serverDone)
buf := make([]byte, 1024)
// 1. Server initiates RFC 1576: DO TERMINAL-TYPE, WILL BINARY, DO BINARY, WILL EOR, DO EOR
offer := []byte{
IAC, DO, OPT_TERMINAL_TYPE,
IAC, WILL, OPT_BINARY,
IAC, DO, OPT_BINARY,
IAC, WILL, OPT_EOR,
IAC, DO, OPT_EOR,
}
if _, err := server.Write(offer); err != nil {
serverDone <- err
return
}
// 2. Read client responses (WILL TERMINAL-TYPE, etc.)
n, err := server.Read(buf)
if err != nil {
serverDone <- err
return
}
// 3. Server asks for terminal type: IAC SB TERMINAL-TYPE 1 IAC SE
askTT := []byte{IAC, SB, OPT_TERMINAL_TYPE, TTYPE_SEND, IAC, SE}
if _, err := server.Write(askTT); err != nil {
serverDone <- err
return
}
// 4. Read client terminal type reply: IAC SB TERMINAL-TYPE 0 IBM-3278-2 IAC SE
n, err = server.Read(buf)
if err != nil {
serverDone <- err
return
}
if !bytes.Contains(buf[:n], []byte{IAC, SB, OPT_TERMINAL_TYPE, TTYPE_IS}) {
serverDone <- fmt.Errorf("expected TERMINAL-TYPE IS, got %x", buf[:n])
return
}
// 5. Server sends initial 3270 screen without TN3270E header
screenPayload := []byte{
CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'I', 'C',
IAC, EOR,
}
if _, err := server.Write(screenPayload); err != nil {
serverDone <- err
return
}
}()
initData, isTN3270E, _, err := negotiateUpstreamTelnet(client, "IBM-3278-2")
if err != nil {
t.Fatalf("negotiateUpstreamTelnet failed: %v", err)
}
if isTN3270E {
t.Fatal("expected isTN3270E == false for fallback")
}
// Any screen data sent by host after negotiation is read from client conn (e.g. by upstreamReader)
screen := initData
if len(screen) == 0 {
buf := make([]byte, 1024)
n, err := client.Read(buf)
if err != nil {
t.Fatalf("Failed to read initial screen after negotiation: %v", err)
}
screen = buf[:n]
}
if err := <-serverDone; err != nil {
t.Fatalf("mock server error: %v", err)
}
expectedScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'I', 'C', IAC, EOR}
if !bytes.Equal(screen, expectedScreen) {
t.Fatalf("expected initial screen %x, got %x", expectedScreen, screen)
}
}
func TestModelMatrixDimensions(t *testing.T) {
tests := []struct {
termType string
expRows int
expCols int
expMax int
}{
{"IBM-3278-2", 24, 80, 8192},
{"IBM-3279-2-E", 24, 80, 8192},
{"IBM-3278-3", 32, 80, 10240},
{"IBM-3279-3-E", 32, 80, 10240},
{"IBM-3278-4", 43, 80, 13760},
{"IBM-3279-4-E", 43, 80, 13760},
{"IBM-3278-5", 27, 132, 14256},
{"IBM-3279-5-E", 27, 132, 14256},
{"UNKNOWN-TERMINAL", 24, 80, 8192},
}
for _, tc := range tests {
rows, cols, maxBuf := GetTerminalMatrix(tc.termType, nil)
if rows != tc.expRows || cols != tc.expCols {
t.Errorf("[%s] Expected %dx%d, got %dx%d", tc.termType, tc.expRows, tc.expCols, rows, cols)
}
if maxBuf != tc.expMax {
t.Errorf("[%s] Expected maxBuf %d, got %d", tc.termType, tc.expMax, maxBuf)
}
}
}
func TestHoldStatusRenderingAndColor(t *testing.T) {
origSystems := ConfiguredSystem
defer func() {
ConfiguredSystem = origSystems
}()
ConfiguredSystem = []TargetSystem{
{Name: "SYS_HELD", Description: "Held system"},
}
slotHeld := &SessionSlot{
System: ConfiguredSystem[0],
IsHeld: true,
}
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
Slots: []*SessionSlot{slotHeld},
}
screen := buildMainMenuScreen(s)
var rowHeld *go3270.Field
for i := range screen {
f := &screen[i]
if f.Row == 7 && strings.Contains(f.Content, "SYS_HELD") {
rowHeld = f
break
}
}
if rowHeld == nil {
t.Fatal("Expected row for SYS_HELD on row 7")
}
if !strings.Contains(rowHeld.Content, "HELD") {
t.Fatalf("Expected row content to contain 'HELD', got: %s", rowHeld.Content)
}
if rowHeld.Color != go3270.Red {
t.Fatalf("Expected HELD row color to be Red (%v), got: %v", go3270.Red, rowHeld.Color)
}
if rowHeld.Intense {
t.Fatal("Expected HELD row to not be intense")
}
}
func TestHoldAndReleaseLineCommands(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "TEST MENU",
PromptSelect: "Selection ===>",
Footer: "Footer",
},
}
ConfiguredSystem = []TargetSystem{
{Name: "TESTSYS"},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'H', 'E', 'L', 'L', 'O', IAC, EOR}
slot := &SessionSlot{
System: ConfiguredSystem[0],
Upstream: upClient,
LastScreen: initialScreen,
MaxBufferSize: 8192,
}
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
ClientConn: clientServer,
ActiveSlotIdx: -1,
IsDisconnected: false,
MenuPage: 0,
Slots: []*SessionSlot{slot},
}
go upstreamReader(s, slot, 0)
loopDone := make(chan struct{})
go func() {
runMenuLoop(s, clientServer)
close(loopDone)
}()
readMenu := func() {
buf := make([]byte, 4096)
_ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := clientUser.Read(buf)
if err != nil {
t.Fatalf("Failed to read menu: %v", err)
}
}
// 1. Initial menu read: slot is ACTIVE
readMenu()
if slot.IsHeld {
t.Fatal("Expected slot to initially not be held")
}
// 2. Send 'H' in cmd_1 to hold the slot
// Row 7, Col 3 = SBA Addr 0xC8, 0xF3, 'H' (EBCDIC 0xC8)
packetHold := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xC8, IAC, EOR}
_, err := clientUser.Write(packetHold)
if err != nil {
t.Fatalf("Failed to write packetHold: %v", err)
}
readMenu()
slot.Mu.Lock()
isHeld := slot.IsHeld
slot.Mu.Unlock()
if !isHeld {
t.Fatal("Expected slot.IsHeld == true after 'H' command")
}
// 3. Send a tick from upstream while held
tickPacket := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x50, 'T', 'I', 'C', 'K', IAC, EOR}
_, _ = upServer.Write(tickPacket)
time.Sleep(100 * time.Millisecond)
slot.Mu.Lock()
screenChanged := slot.ScreenChanged
slot.Mu.Unlock()
if screenChanged {
t.Fatal("Expected ScreenChanged to remain false while slot is held")
}
// 4. Send 'R' in cmd_1 to release the slot
// 'R' in EBCDIC 037 is 0xD9
packetRelease := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xD9, IAC, EOR}
_, err = clientUser.Write(packetRelease)
if err != nil {
t.Fatalf("Failed to write packetRelease: %v", err)
}
readMenu()
slot.Mu.Lock()
isHeldAfterR := slot.IsHeld
slot.Mu.Unlock()
if isHeldAfterR {
t.Fatal("Expected slot.IsHeld == false after 'R' command")
}
// 5. Send a tick now that it is released; it should trigger ScreenChanged
_, _ = upServer.Write(tickPacket)
time.Sleep(100 * time.Millisecond)
slot.Mu.Lock()
screenChangedAfterR := slot.ScreenChanged
slot.Mu.Unlock()
if !screenChangedAfterR {
t.Fatal("Expected ScreenChanged to be true after tick received on released slot")
}
// Clean exit via PF3
packetPF3 := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR}
_, _ = clientUser.Write(packetPF3)
discBuf := make([]byte, 2048)
_, _ = clientUser.Read(discBuf)
select {
case <-loopDone:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for runMenuLoop to exit")
}
}
func TestBackgroundStartCommand(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
// Start a mock upstream server for SYS1
upListener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to bind mock upstream: %v", err)
}
defer upListener.Close()
upAddr := upListener.Addr().String()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "TEST MENU",
PromptSelect: "Selection ===>",
Footer: "Footer",
},
}
ConfiguredSystem = []TargetSystem{
{Name: "SYS_BG", HostPort: upAddr},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
ClientConn: clientServer,
ActiveSlotIdx: -1,
IsDisconnected: false,
MenuPage: 0,
Slots: make([]*SessionSlot, 0),
}
// Accept loop for mock upstream
go func() {
conn, err := upListener.Accept()
if err != nil {
return
}
defer conn.Close()
// Send telnet negotiations + initial screen
_, _ = conn.Write([]byte{IAC, DO, OPT_BINARY, IAC, DO, OPT_EOR})
buf := make([]byte, 256)
_, _ = conn.Read(buf)
_, _ = conn.Write([]byte{CMD_ERASE_WRITE, 0xC3, 'B', 'G', IAC, EOR})
time.Sleep(1 * time.Second)
}()
loopDone := make(chan struct{})
go func() {
runMenuLoop(s, clientServer)
close(loopDone)
}()
readMenu := func() {
buf := make([]byte, 4096)
_ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := clientUser.Read(buf)
if err != nil {
t.Fatalf("Failed to read menu: %v", err)
}
}
// Read initial menu: 0 active slots
readMenu()
if s.CountActiveSlots() != 0 {
t.Fatalf("Expected 0 active slots initially, got %d", s.CountActiveSlots())
}
// Send 'B' in cmd_1 to connect SYS_BG in background
// 'B' in EBCDIC 037 is 0xC2
packetB := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xC2, IAC, EOR}
_, err = clientUser.Write(packetB)
if err != nil {
t.Fatalf("Failed to write packetB: %v", err)
}
// Re-rendered menu read
readMenu()
// Verify slot was started in background
if s.CountActiveSlots() != 1 {
t.Fatalf("Expected 1 active slot after 'B', got %d", s.CountActiveSlots())
}
s.Mu.Lock()
activeIdx := s.ActiveSlotIdx
s.Mu.Unlock()
if activeIdx != -1 {
t.Fatalf("Expected ActiveSlotIdx == -1 (still on menu), got %d", activeIdx)
}
// Clean exit via PF3
packetPF3 := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR}
_, _ = clientUser.Write(packetPF3)
discBuf := make([]byte, 2048)
_, _ = clientUser.Read(discBuf)
select {
case <-loopDone:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for runMenuLoop to exit")
}
}
func TestBoundedBufferPreventsRunawayTicks(t *testing.T) {
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'E', IAC, EOR}
slot := &SessionSlot{
System: TargetSystem{Name: "TICK_SYSTEM"},
Upstream: upClient,
LastScreen: initialScreen,
MaxBufferSize: 8192,
}
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
TerminalType: "IBM-3278-2",
ClientConn: clientServer,
ActiveSlotIdx: -1,
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(s, slot, 0)
// Stream 300 incremental write updates, each ~50 bytes (total 15,000 bytes > 8,192 MaxBufferSize)
writePayload := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x60}
for i := 0; i < 40; i++ {
writePayload = append(writePayload, 'A'+byte(i%26))
}
writePayload = append(writePayload, IAC, EOR)
for i := 0; i < 300; i++ {
_, err := upServer.Write(writePayload)
if err != nil {
t.Fatalf("Failed to write tick %d: %v", i, err)
}
}
time.Sleep(200 * time.Millisecond)
slot.Mu.Lock()
bufLen := len(slot.LastScreen)
screenCopy := make([]byte, bufLen)
copy(screenCopy, slot.LastScreen)
slot.Mu.Unlock()
// Verify buffer did NOT grow unbounded and stayed strictly within MaxBufferSize
if bufLen > slot.MaxBufferSize {
t.Fatalf("Buffer grew unbounded! Expected len <= %d, got %d", slot.MaxBufferSize, bufLen)
}
// Verify buffer still starts with Erase/Write opcode (0xF5) and ends with IAC EOR
if screenCopy[0] != CMD_ERASE_WRITE {
t.Fatalf("Expected buffer to retain Erase/Write header 0xF5, got 0x%02X", screenCopy[0])
}
if !bytes.HasSuffix(screenCopy, telnetEOR) {
t.Fatalf("Expected buffer to end with IAC EOR, got %x", screenCopy[len(screenCopy)-2:])
}
}
func TestResolveTerminalType(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"IBM-3278-2", "IBM-3278-2-E"},
{"IBM-3278-3", "IBM-3278-3-E"},
{"IBM-3278-4", "IBM-3278-4-E"},
{"IBM-3278-5", "IBM-3278-5-E"},
{"IBM-3279-2", "IBM-3279-2-E"},
{"IBM-3279-3", "IBM-3279-3-E"},
{"IBM-3279-4", "IBM-3279-4-E"},
{"IBM-3279-5", "IBM-3279-5-E"},
{"IBM-3279-4-E", "IBM-3279-4-E"},
{"unknown (IBM-3279-4)", "IBM-3279-4-E"},
{"unknown (IBM-3278-3-E)", "IBM-3278-3-E"},
{"", "IBM-3279-2-E"},
}
for _, tc := range tests {
actual := ResolveTerminalType(tc.input, nil)
if actual != tc.expected {
t.Errorf("ResolveTerminalType(%q) = %q, expected %q", tc.input, actual, tc.expected)
}
}
}
func TestBackgroundStartWithAttachedTerminalCharacteristics(t *testing.T) {
origConfig := GlobalConfig
origSystems := ConfiguredSystem
defer func() {
GlobalConfig = origConfig
ConfiguredSystem = origSystems
}()
upListener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to bind mock upstream: %v", err)
}
defer upListener.Close()
upAddr := upListener.Addr().String()
GlobalConfig = &Config{
Menu: MenuConfig{
Title: "TEST MENU",
PromptSelect: "Selection ===>",
Footer: "Footer",
},
}
ConfiguredSystem = []TargetSystem{
{Name: "SYS_MODEL4", HostPort: upAddr},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
// Attached terminal is a Model 4 color terminal (43 rows, 80 cols)
s := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
TerminalType: "IBM-3279-4-E",
ClientConn: clientServer,
ActiveSlotIdx: -1,
IsDisconnected: false,
MenuPage: 0,
Slots: make([]*SessionSlot, 0),
}
negotiatedTermType := make(chan string, 1)
queryReplyReceived := make(chan []byte, 1)
// Mock upstream server simulating a mainframe
go func() {
conn, err := upListener.Accept()
if err != nil {
return
}
defer conn.Close()
// Request Terminal Type
_, _ = conn.Write([]byte{IAC, DO, OPT_TERMINAL_TYPE, IAC, DO, OPT_BINARY, IAC, DO, OPT_EOR})
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
return
}
_ = n
// Send terminal type send request
_, _ = conn.Write([]byte{IAC, SB, OPT_TERMINAL_TYPE, TTYPE_SEND, IAC, SE})
n, err = conn.Read(buf)
if err != nil {
return
}
data := buf[:n]
// Extract reported terminal type
ttypeIdx := bytes.Index(data, []byte{IAC, SB, OPT_TERMINAL_TYPE, TTYPE_IS})
if ttypeIdx != -1 {
seIdx := bytes.Index(data[ttypeIdx:], []byte{IAC, SE})
if seIdx != -1 {
term := string(data[ttypeIdx+4 : ttypeIdx+seIdx])
negotiatedTermType <- term
}
}
// Now send a 3270 Query command (Write Structured Field with Read Partition Query)
queryCmd := []byte{CMD_WSF, 0x00, 0x05, SF_READ_PARTITION, SF_RP_QUERY, 0xFF, 0x02, IAC, EOR}
_, _ = conn.Write(queryCmd)
// Read Query Reply from background session
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
qrBuf := make([]byte, 2048)
qrN, err := conn.Read(qrBuf)
if err == nil && qrN > 0 {
queryReplyReceived <- qrBuf[:qrN]
}
}()
loopDone := make(chan struct{})
go func() {
runMenuLoop(s, clientServer)
close(loopDone)
}()
readMenu := func() {
buf := make([]byte, 4096)
_ = clientUser.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := clientUser.Read(buf)
if err != nil {
t.Fatalf("Failed to read menu: %v", err)
}
}
readMenu()
// Send 'B' in cmd_1
packetB := []byte{0x7D, 0x40, 0x40, 0x11, 0xC8, 0xF3, 0xC2, IAC, EOR}
_, err = clientUser.Write(packetB)
if err != nil {
t.Fatalf("Failed to write packetB: %v", err)
}
readMenu()
// 1. Verify negotiated terminal type matches the attached Model 4 terminal
select {
case term := <-negotiatedTermType:
if term != "IBM-3279-4-E" {
t.Fatalf("Expected negotiated terminal type 'IBM-3279-4-E', got '%s'", term)
}
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for negotiated terminal type")
}
// 2. Verify Query Reply was sent in the background and reports Model 4 (43 rows, 80 cols)
select {
case qr := <-queryReplyReceived:
if len(qr) < 10 || qr[0] != AID_QUERY_REPLY {
t.Fatalf("Expected AID Query Reply (0x88), got: %x", qr)
}
// Check Usable Area inside Query Reply
uaIdx := bytes.Index(qr, []byte{SF_QUERY_REPLY, QR_USABLE_AREA})
if uaIdx == -1 {
t.Fatalf("Expected Usable Area structured field in Query Reply: %x", qr)
}
cols := int(qr[uaIdx+4])<<8 | int(qr[uaIdx+5])
rows := int(qr[uaIdx+6])<<8 | int(qr[uaIdx+7])
if rows != 43 || cols != 80 {
t.Fatalf("Expected Query Reply to report 43x80, got %dx%d", rows, cols)
}
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for Query Reply in background session")
}
// Clean exit via PF3
packetPF3 := []byte{byte(go3270.AIDPF3), 0x40, 0x40, IAC, EOR}
_, _ = clientUser.Write(packetPF3)
discBuf := make([]byte, 2048)
_, _ = clientUser.Read(discBuf)
select {
case <-loopDone:
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for runMenuLoop to exit")
}
}
func TestTN3270ESSCPLUDataInputBeforeBind(t *testing.T) {
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
clientTerm, sessmanTerm := net.Pipe()
defer clientTerm.Close()
defer sessmanTerm.Close()
slot := &SessionSlot{
System: TargetSystem{Name: "ZOS_SYSTEM"},
Upstream: upClient,
IsTN3270E: true,
IsBound: false, // Initial state before BIND
}
userSession := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
ClientConn: sessmanTerm,
ActiveSlotIdx: 0,
Slots: []*SessionSlot{slot},
}
upstreamGot := make(chan []byte, 1)
go func() {
buf := make([]byte, 1024)
n, err := upServer.Read(buf)
if err == nil {
upstreamGot <- append([]byte{}, buf[:n]...)
}
}()
bridgeDone := make(chan struct{})
go func() {
defer close(bridgeDone)
bridgeClientToUpstream(userSession, slot, sessmanTerm)
}()
clientPacket := []byte{0x7D, 0x40, 0x40, 'L', 'O', 'G', 'O', 'N', IAC, EOR}
if _, err := clientTerm.Write(clientPacket); err != nil {
t.Fatalf("Write failed: %v", err)
}
select {
case data := <-upstreamGot:
if len(data) < 5 {
t.Fatalf("Upstream packet too short: %x", data)
}
if data[0] != TN3270E_DATA_SSCP_LU {
t.Fatalf("Expected TN3270E_DATA_SSCP_LU (0x07), got 0x%02x", data[0])
}
if data[3] != 0 || data[4] != 0 {
t.Fatalf("Expected seq number 0x0000 for SSCP-LU, got 0x%02x%02x", data[3], data[4])
}
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for upstream data")
}
_ = clientTerm.Close()
<-bridgeDone
}
func TestTN3270EUNBINDDoesNotClearSlotAndTransitionsToSSCP(t *testing.T) {
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
clientTerm, sessmanTerm := net.Pipe()
defer clientTerm.Close()
defer sessmanTerm.Close()
slot := &SessionSlot{
System: TargetSystem{Name: "ZOS_SYSTEM"},
Upstream: upClient,
IsTN3270E: true,
IsBound: true, // Initially bound
}
userSession := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
ClientConn: sessmanTerm,
ActiveSlotIdx: 0,
Slots: []*SessionSlot{slot},
}
go upstreamReader(userSession, slot, 0)
unbindPacket := []byte{TN3270E_DATA_UNBIND, 0x00, 0x00, 0x00, 0x00, 0x02, IAC, EOR}
if _, err := upServer.Write(unbindPacket); err != nil {
t.Fatalf("Failed to write UNBIND: %v", err)
}
time.Sleep(50 * time.Millisecond)
userSession.Mu.Lock()
if userSession.Slots[0] == nil {
userSession.Mu.Unlock()
t.Fatal("Slot 0 was unexpectedly cleared on UNBIND")
}
userSession.Mu.Unlock()
slot.Mu.Lock()
if slot.Upstream == nil {
slot.Mu.Unlock()
t.Fatal("slot.Upstream was unexpectedly closed on UNBIND")
}
if slot.IsBound {
slot.Mu.Unlock()
t.Fatal("slot.IsBound should be false after UNBIND")
}
slot.Mu.Unlock()
bindPacket := []byte{TN3270E_DATA_BIND, 0x00, 0x00, 0x00, 0x00, 0x31, IAC, EOR}
if _, err := upServer.Write(bindPacket); err != nil {
t.Fatalf("Failed to write BIND: %v", err)
}
time.Sleep(50 * time.Millisecond)
slot.Mu.Lock()
if !slot.IsBound {
slot.Mu.Unlock()
t.Fatal("slot.IsBound should be true after BIND")
}
slot.Mu.Unlock()
_ = upServer.Close()
}
func TestTN3270EBackgroundQueryReplyUsesSSCPLUWhenUnbound(t *testing.T) {
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
slot := &SessionSlot{
System: TargetSystem{Name: "ZOS_SYSTEM"},
Upstream: upClient,
IsTN3270E: true,
IsBound: false,
}
userSession := &UserMasterSession{
Username: "TESTUSER",
DisplayTag: "12345678",
TerminalType: "IBM-3279-4-E",
ActiveSlotIdx: -1, // Background
Slots: []*SessionSlot{slot},
}
go upstreamReader(userSession, slot, 0)
gotReply := make(chan []byte, 1)
go func() {
buf := make([]byte, 2048)
n, err := upServer.Read(buf)
if err == nil {
gotReply <- append([]byte{}, buf[:n]...)
}
}()
queryPacket := []byte{
TN3270E_DATA_SSCP_LU, 0x00, 0x00, 0x00, 0x00,
CMD_WSF, 0x00, 0x05, SF_READ_PARTITION, SF_RP_QUERY, 0x02,
IAC, EOR,
}
if _, err := upServer.Write(queryPacket); err != nil {
t.Fatalf("Failed to write Query: %v", err)
}
select {
case reply := <-gotReply:
if len(reply) < 5 {
t.Fatalf("Reply too short: %x", reply)
}
if reply[0] != TN3270E_DATA_SSCP_LU {
t.Fatalf("Expected Query Reply to use TN3270E_DATA_SSCP_LU (0x07), got 0x%02x", reply[0])
}
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for background Query Reply")
}
_ = upServer.Close()
}
func TestCaseSensitiveUsernamesAndSessionKeys(t *testing.T) {
origConfig := GlobalConfig
origPub := publicFlag
defer func() {
GlobalConfig = origConfig
publicFlag = origPub
}()
publicFlag = false
// 1. Session keys must differ for "RUDI" and "rudi"
keyLower := computeSessionKey("rudi", "samepass")
keyUpper := computeSessionKey("RUDI", "samepass")
if keyLower == keyUpper {
t.Fatalf("Expected distinct session keys for 'rudi' and 'RUDI', but both produced %s", keyLower)
}
// 2. Authenticate distinct users "rudi" (non-admin) and "RUDI" (admin)
GlobalConfig = &Config{
Users: []UserConfig{
{Username: "rudi", Password: "lowerpass", Admin: false},
{Username: "RUDI", Password: "UPPERPASS", Admin: true},
},
}
resLower := authenticateAndAuthorize("rudi", "lowerpass")
if !resLower.OK || resLower.IsAdmin {
t.Fatalf("Expected 'rudi' to authenticate as non-admin, got %+v", resLower)
}
resUpper := authenticateAndAuthorize("RUDI", "UPPERPASS")
if !resUpper.OK || !resUpper.IsAdmin {
t.Fatalf("Expected 'RUDI' to authenticate as admin, got %+v", resUpper)
}
if res := authenticateAndAuthorize("rudi", "UPPERPASS"); res.OK {
t.Fatal("Expected 'rudi' with 'UPPERPASS' to fail authentication")
}
if res := authenticateAndAuthorize("RUDI", "lowerpass"); res.OK {
t.Fatal("Expected 'RUDI' with 'lowerpass' to fail authentication")
}
// 3. findSession must match Username case-sensitively
sessLower := &UserMasterSession{Username: "rudi", SessionKey: keyLower, DisplayTag: keyLower[:8]}
sessUpper := &UserMasterSession{Username: "RUDI", SessionKey: keyUpper, DisplayTag: keyUpper[:8]}
registryMu.Lock()
userSessions = map[string]*UserMasterSession{
keyLower: sessLower,
keyUpper: sessUpper,
}
registryMu.Unlock()
if got := findSession("rudi"); got != sessLower {
t.Fatalf("Expected findSession('rudi') to return sessLower, got %+v", got)
}
if got := findSession("RUDI"); got != sessUpper {
t.Fatalf("Expected findSession('RUDI') to return sessUpper, got %+v", got)
}
if got := findSession("Rudi"); got != nil {
t.Fatalf("Expected findSession('Rudi') to return nil, got %+v", got)
}
}
func TestEscapedIACEORInRecordFraming(t *testing.T) {
// A 3270 record containing an escaped 0xFF 0xFF followed immediately by 0xEF,
// and then ending with a real unescaped IAC EOR (0xFF 0xEF).
record := []byte{CMD_ERASE_WRITE, 0xC3, IAC, IAC, EOR, 'O', 'K', IAC, EOR}
idx := indexTelnetEOR(record)
if idx != len(record)-2 {
t.Fatalf("Expected indexTelnetEOR to skip escaped IAC IAC and find EOR at %d, got %d", len(record)-2, idx)
}
lastIdx := lastIndexTelnetEOR(record)
if lastIdx != len(record)-2 {
t.Fatalf("Expected lastIndexTelnetEOR to find EOR at %d, got %d", len(record)-2, lastIdx)
}
}
func TestCleanupSessionIfEmpty(t *testing.T) {
sess := &UserMasterSession{
Username: "tempuser",
SessionKey: "tempkey12345678",
DisplayTag: "tempkey1",
IsDisconnected: true,
Slots: []*SessionSlot{nil},
}
registryMu.Lock()
userSessions = map[string]*UserMasterSession{
sess.SessionKey: sess,
}
registryMu.Unlock()
cleanupSessionIfEmpty(sess)
registryMu.Lock()
_, exists := userSessions[sess.SessionKey]
registryMu.Unlock()
if exists {
t.Fatal("Expected disconnected session with 0 active slots to be removed from userSessions")
}
}
func TestAutoHoldOnDiscardLoopBufferOverflow(t *testing.T) {
origSystems := ConfiguredSystem
defer func() {
ConfiguredSystem = origSystems
}()
ConfiguredSystem = []TargetSystem{
{Name: "DISCARD_SYS", Description: "Discard loop system"},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'E', IAC, EOR}
slot := &SessionSlot{
System: ConfiguredSystem[0],
Upstream: upClient,
LastScreen: initialScreen,
MaxBufferSize: 200, // Small buffer so it easily overflows
AutoHoldBurstLimit: -1, // Disable burst rate check to test discard check specifically
AutoHoldDiscardLimit: 3, // 3 discards triggers auto-hold
}
s := &UserMasterSession{
Username: "DISCARDUSER",
DisplayTag: "disc1234",
TerminalType: "IBM-3278-2",
ClientConn: clientServer,
ActiveSlotIdx: -1, // Background
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(s, slot, 0)
// Send 10 writes of 60 bytes each (each > 200/2, quickly overflowing 200-byte MaxBufferSize)
writePayload := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x60}
for i := 0; i < 50; i++ {
writePayload = append(writePayload, 'X')
}
writePayload = append(writePayload, IAC, EOR)
for i := 0; i < 10; i++ {
_, err := upServer.Write(writePayload)
if err != nil {
t.Fatalf("Failed to write payload %d: %v", i, err)
}
time.Sleep(10 * time.Millisecond)
}
time.Sleep(150 * time.Millisecond)
slot.Mu.Lock()
isHeld := slot.IsHeld
screenChanged := slot.ScreenChanged
slot.Mu.Unlock()
if !isHeld {
t.Fatal("Expected slot.IsHeld == true after buffer overflow discard loop")
}
if screenChanged {
t.Fatal("Expected slot.ScreenChanged == false once placed on automatic HOLD")
}
// Verify status in main menu is rendered as "HELD" with color Red
screen := buildMainMenuScreen(s)
var rowHeld *go3270.Field
for i := range screen {
f := &screen[i]
if f.Row == 7 && strings.Contains(f.Content, "DISCARD_SYS") {
rowHeld = f
break
}
}
if rowHeld == nil {
t.Fatal("Expected row for DISCARD_SYS on row 7")
}
if !strings.Contains(rowHeld.Content, "HELD") {
t.Fatalf("Expected row content to contain 'HELD', got: %s", rowHeld.Content)
}
if rowHeld.Color != go3270.Red {
t.Fatalf("Expected HELD row color to be Red, got: %v", rowHeld.Color)
}
// Releasing the hold resets counters
slot.Mu.Lock()
slot.resetHoldAndLoopCountersLocked()
isHeldAfterReset := slot.IsHeld
discardCountAfterReset := slot.DiscardCount
slot.Mu.Unlock()
if isHeldAfterReset {
t.Fatal("Expected IsHeld == false after resetHoldAndLoopCountersLocked")
}
if discardCountAfterReset != 0 {
t.Fatalf("Expected DiscardCount == 0, got %d", discardCountAfterReset)
}
}
func TestAutoHoldOnExcessiveUpdateRate(t *testing.T) {
origSystems := ConfiguredSystem
defer func() {
ConfiguredSystem = origSystems
}()
ConfiguredSystem = []TargetSystem{
{Name: "BURST_SYS", Description: "Burst loop system"},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'E', IAC, EOR}
slot := &SessionSlot{
System: ConfiguredSystem[0],
Upstream: upClient,
LastScreen: initialScreen,
MaxBufferSize: 32768, // Huge buffer, will NOT overflow
AutoHoldBurstLimit: 5, // 5 rapid updates triggers auto-hold
AutoHoldDiscardLimit: -1, // Disable discard check
}
s := &UserMasterSession{
Username: "BURSTUSER",
DisplayTag: "brst1234",
TerminalType: "IBM-3278-2",
ClientConn: clientServer,
ActiveSlotIdx: -1, // Background
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(s, slot, 0)
// Send 6 small updates in rapid succession
smallUpdate := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x50, '1', IAC, EOR}
for i := 0; i < 6; i++ {
_, err := upServer.Write(smallUpdate)
if err != nil {
t.Fatalf("Failed to write small update %d: %v", i, err)
}
}
time.Sleep(100 * time.Millisecond)
slot.Mu.Lock()
isHeld := slot.IsHeld
screenChanged := slot.ScreenChanged
slot.Mu.Unlock()
if !isHeld {
t.Fatal("Expected slot.IsHeld == true after excessive update rate loop")
}
if screenChanged {
t.Fatal("Expected slot.ScreenChanged == false once placed on automatic HOLD")
}
// Menu renders HELD
screen := buildMainMenuScreen(s)
var rowHeld *go3270.Field
for i := range screen {
f := &screen[i]
if f.Row == 7 && strings.Contains(f.Content, "BURST_SYS") {
rowHeld = f
break
}
}
if rowHeld == nil {
t.Fatal("Expected row for BURST_SYS on row 7")
}
if !strings.Contains(rowHeld.Content, "HELD") {
t.Fatalf("Expected row content to contain 'HELD', got: %s", rowHeld.Content)
}
}
func TestActiveSlotDoesNotAutoHold(t *testing.T) {
origSystems := ConfiguredSystem
defer func() {
ConfiguredSystem = origSystems
}()
ConfiguredSystem = []TargetSystem{
{Name: "ACTIVE_SYS", Description: "Active interactive system"},
}
clientServer, clientUser := net.Pipe()
defer clientServer.Close()
defer clientUser.Close()
upServer, upClient := net.Pipe()
defer upServer.Close()
defer upClient.Close()
// Drain clientUser in background
go func() {
buf := make([]byte, 4096)
for {
_, err := clientUser.Read(buf)
if err != nil {
return
}
}
}()
initialScreen := []byte{CMD_ERASE_WRITE, 0xC3, 0x11, 0x40, 0x40, 'B', 'A', 'S', 'E', IAC, EOR}
slot := &SessionSlot{
System: ConfiguredSystem[0],
Upstream: upClient,
LastScreen: initialScreen,
MaxBufferSize: 200,
AutoHoldBurstLimit: 5,
}
s := &UserMasterSession{
Username: "ACTIVEUSER",
DisplayTag: "actv1234",
TerminalType: "IBM-3278-2",
ClientConn: clientServer,
ActiveSlotIdx: 0, // ACTIVE (user is viewing slot 0)
Slots: []*SessionSlot{slot},
IsDisconnected: false,
}
go upstreamReader(s, slot, 0)
// Send 10 writes rapidly
smallUpdate := []byte{CMD_WRITE, 0xC3, 0x11, 0x40, 0x50, 'Z', IAC, EOR}
for i := 0; i < 10; i++ {
_, err := upServer.Write(smallUpdate)
if err != nil {
t.Fatalf("Failed to write update %d: %v", i, err)
}
}
time.Sleep(100 * time.Millisecond)
slot.Mu.Lock()
isHeld := slot.IsHeld
slot.Mu.Unlock()
if isHeld {
t.Fatal("Expected active slot (user watching) to NOT be placed on automatic HOLD")
}
}