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