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