diff --git a/src/main/java/haus/nightmare/a3270/ui/ConnectChoiceDialog.kt b/src/main/java/haus/nightmare/a3270/ui/ConnectChoiceDialog.kt new file mode 100644 index 0000000..2ba5180 --- /dev/null +++ b/src/main/java/haus/nightmare/a3270/ui/ConnectChoiceDialog.kt @@ -0,0 +1,109 @@ +package haus.nightmare.a3270.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +enum class ConnectChoice { + NEW_WINDOW, + ABORT_CURRENT, + CANCEL +} + +@Composable +fun ConnectChoiceDialog( + currentTarget: String, + onChoice: (ConnectChoice) -> Unit, + onDismiss: () -> Unit +) { + val targetText = currentTarget.ifBlank { "the host" } + + Dialog( + onDismissRequest = { + onChoice(ConnectChoice.CANCEL) + onDismiss() + }, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true) + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = Color(0xFF1E1E1E), + tonalElevation = 6.dp, + modifier = Modifier + .fillMaxWidth(0.95f) + .padding(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Connection Already Active", + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = Color.White + ) + + Text( + text = "a3270 is currently connected to $targetText.", + fontSize = 13.sp, + color = Color.LightGray + ) + + Text( + text = "Would you like to open the connection in a new window or abort the current connection?", + fontSize = 13.sp, + color = Color.LightGray + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { + onChoice(ConnectChoice.CANCEL) + onDismiss() + } + ) { + Text("Cancel", color = Color.Gray) + } + + Button( + onClick = { + onChoice(ConnectChoice.ABORT_CURRENT) + onDismiss() + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)) + ) { + Text("Abort Current Connection", fontSize = 11.sp) + } + + Button( + onClick = { + onChoice(ConnectChoice.NEW_WINDOW) + onDismiss() + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6)) + ) { + Text("New Window", fontSize = 11.sp, fontWeight = FontWeight.Bold) + } + } + } + } + } +} diff --git a/src/main/res/xml/file_paths.xml b/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..1697883 --- /dev/null +++ b/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/test/java/haus/nightmare/a3270/ConnectionAndHostDirectoryTest.kt b/src/test/java/haus/nightmare/a3270/ConnectionAndHostDirectoryTest.kt new file mode 100644 index 0000000..e7b1455 --- /dev/null +++ b/src/test/java/haus/nightmare/a3270/ConnectionAndHostDirectoryTest.kt @@ -0,0 +1,318 @@ +package haus.nightmare.a3270 + +import haus.nightmare.a3270.storage.AppSettings +import haus.nightmare.a3270.storage.HostStorage +import haus.nightmare.a3270.storage.SavedHost +import haus.nightmare.a3270.ui.ConnectChoice +import haus.nightmare.a3270.ui.LastTransferState +import haus.nightmare.a3270.ui.formatCMSFilter +import haus.nightmare.a3270.ui.matchesCmsFilemode +import haus.nightmare.a3270.ui.matchesCmsFilter +import haus.nightmare.a3270.ui.matchesFilter +import haus.nightmare.a3270.ui.matchesTsoFilter +import haus.nightmare.a3270.ui.matchesWildcard +import haus.nightmare.a3270.ui.resolveDefaultQuery +import haus.nightmare.lib3270j.ConnectionConfig +import haus.nightmare.lib3270j.TerminalModel +import haus.nightmare.lib3270j.ft.FTConfig +import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry +import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry +import haus.nightmare.lib3270j.graphics.GraphicsMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.StringReader +import java.util.UUID + +class ConnectionAndHostDirectoryTest { + + @Test + fun testSavedHostToConnectionConfigPreservesMetadata() { + val hostId = UUID.randomUUID().toString() + val host = SavedHost( + id = hostId, + name = "MVS Production LPAR", + host = "mvs.prod.corp", + port = 992, + model = 4, + dynamicRows = 62, + dynamicCols = 160, + luName = "LU3270A", + autoConnect = false, + hostType = "TSO", + useTls = true, + tlsVerifyCert = true, + tn3270e = true, + graphicsMode = "VECTOR_GRAPHICS", + codePage = "1047", + proxyType = "SOCKS5", + proxyHost = "proxy.corp", + proxyPort = 1080, + proxyUsername = "mvsadmin", + proxyPassword = "mypassword" + ) + + val config = host.toConnectionConfig() + + assertEquals("mvs.prod.corp", config.host) + assertEquals(992, config.port) + assertEquals(TerminalModel.IBM_3279_4, config.model) + assertEquals("LU3270A", config.luName) + assertTrue(config.isUseTls) + assertTrue(config.isTlsVerifyCert) + assertTrue(config.isTn3270eEnabled) + assertEquals(GraphicsMode.VECTOR_GRAPHICS, config.graphicsMode) + assertEquals("1047", config.codePage) + assertEquals(ConnectionConfig.ProxyType.SOCKS5, config.proxyType) + assertEquals("proxy.corp", config.proxyHost) + assertEquals(1080, config.proxyPort) + assertEquals("mvsadmin", config.proxyUsername) + assertEquals("mypassword", config.proxyPassword) + + // Metadata preservation added in connection hardening + assertTrue("savedConnection flag must be true", config.isSavedConnection) + assertEquals(hostId, config.savedProfileId) + assertEquals("MVS Production LPAR", config.savedProfileName) + assertEquals("TSO", config.hostType) + assertEquals(FTConfig.HostType.TSO, config.ftHostType) + } + + @Test + fun testSavedHostToConnectionConfigCmsHostType() { + val host = SavedHost( + id = "cms-1", + name = "VM/CMS Guest", + host = "vm.local", + port = 23, + hostType = "CMS" + ) + val config = host.toConnectionConfig() + assertEquals("CMS", config.hostType) + assertEquals(FTConfig.HostType.CMS, config.ftHostType) + } + + @Test + fun testResolveDefaultQuery() { + assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, null)) + assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "")) + assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, " ")) + assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "*")) + assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "* * *")) + assertEquals("PROFILE EXEC A", resolveDefaultQuery(FTConfig.HostType.CMS, "PROFILE EXEC A")) + + assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, null)) + assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, "")) + assertEquals("SYS1.*", resolveDefaultQuery(FTConfig.HostType.TSO, "SYS1.*")) + } + + @Test + fun testFormatCMSFilter() { + assertEquals("* * A", formatCMSFilter(null)) + assertEquals("* * A", formatCMSFilter("")) + assertEquals("* * A", formatCMSFilter(" ")) + assertEquals("* * A", formatCMSFilter("*")) + assertEquals("* * A", formatCMSFilter("* * *")) + assertEquals("MYFILE * A", formatCMSFilter("MYFILE")) + assertEquals("MYFILE EXEC A", formatCMSFilter("MYFILE EXEC")) + assertEquals("MYFILE EXEC B", formatCMSFilter("MYFILE EXEC B")) + assertEquals("PROFILE EXEC A", formatCMSFilter("PROFILE.EXEC")) + assertEquals("PROFILE EXEC A", formatCMSFilter("'PROFILE.EXEC'")) + } + + @Test + fun testMatchesWildcard() { + assertTrue(matchesWildcard("HELLO", "*")) + assertTrue(matchesWildcard("HELLO", "H*")) + assertTrue(matchesWildcard("HELLO", "H*O")) + assertTrue(matchesWildcard("HELLO", "H?LLO")) + assertTrue(matchesWildcard("HELLO", "?????")) + assertFalse(matchesWildcard("HELLO", "H?L")) + assertFalse(matchesWildcard("HELLO", "WORLD")) + + assertTrue(matchesWildcard("DATA.SET.NAME", "*.NAME")) + assertTrue(matchesWildcard("DATA.SET.NAME", "DATA.*")) + assertFalse(matchesWildcard("DATA.SET.NAME", "SYS1.*")) + } + + @Test + fun testMatchesCmsFilterAndFilemode() { + val entry = CMSDirectoryEntry("PROFILE", "EXEC", "A1").apply { + recfm = "F" + lrecl = 80 + blksize = 80 + } + + assertTrue(matchesFilter(entry, null)) + assertTrue(matchesFilter(entry, "")) + assertTrue(matchesFilter(entry, "* * *")) + assertTrue(matchesFilter(entry, "* * A")) + assertTrue(matchesFilter(entry, "PROFILE * A")) + assertTrue(matchesFilter(entry, "PROFILE EXEC *")) + assertTrue(matchesFilter(entry, "PROFILE EXEC A1")) + assertTrue(matchesFilter(entry, "PROFILE.EXEC")) + assertTrue(matchesFilter(entry, "PROFILE")) + assertTrue(matchesFilter(entry, "EXEC")) + + assertFalse(matchesFilter(entry, "* * B")) + assertFalse(matchesFilter(entry, "TEST * *")) + assertFalse(matchesFilter(entry, "*.COBOL")) + assertFalse(matchesFilter(entry, "PROFILE EXEC A2")) + + assertTrue(matchesCmsFilemode("A1", "A")) + assertTrue(matchesCmsFilemode("A1", "A1")) + assertTrue(matchesCmsFilemode("A1", "*")) + assertFalse(matchesCmsFilemode("A1", "B")) + assertFalse(matchesCmsFilemode("A1", "A2")) + } + + @Test + fun testMatchesTsoFilter() { + val entry = TSODirectoryEntry("USER.SOURCE.COBOL").apply { + volume = "VOL001" + dsorg = "PO" + } + + assertTrue(matchesFilter(entry, null)) + assertTrue(matchesFilter(entry, "")) + assertTrue(matchesFilter(entry, "*")) + assertTrue(matchesFilter(entry, "*SOURCE*")) + assertTrue(matchesFilter(entry, "USER.*.COBOL")) + assertTrue(matchesFilter(entry, "COBOL")) // substring search + assertTrue(matchesFilter(entry, "USER.SOURCE")) + + assertFalse(matchesFilter(entry, "SYS1.*")) + assertFalse(matchesFilter(entry, "*.PLI")) + assertFalse(matchesFilter(entry, "FORTRAN")) + } + + @Test + fun testAppSettingsIniHostTypeParsing() { + val testIni = """ + [Connection] + hostType = CMS + + [General] + defaultHostType = CICS + """.trimIndent() + + var parsedHostType: String? = null + AppSettings.parseIni(StringReader(testIni)) { _, key, value -> + if (key.equals("hostType", ignoreCase = true) || + key.equals("hosttype", ignoreCase = true) || + key.equals("host_type", ignoreCase = true) || + key.equals("defaulthosttype", ignoreCase = true) + ) { + parsedHostType = value + } + } + + assertNotNull(parsedHostType) + // First or second value parsed + assertTrue(parsedHostType.equals("CMS", ignoreCase = true) || parsedHostType.equals("CICS", ignoreCase = true)) + } + + @Test + fun testLastTransferStateUnitsAndDirBlocks() { + val state = LastTransferState() + assertEquals(FTConfig.HostType.TSO, state.hostType) + assertEquals(FTConfig.AllocationUnit.TRACKS, state.units) + assertEquals("", state.dirBlocks) + + state.units = FTConfig.AllocationUnit.TRACKS + state.dirBlocks = "20" + state.hostType = FTConfig.HostType.CMS + + assertEquals(FTConfig.AllocationUnit.TRACKS, state.units) + assertEquals("20", state.dirBlocks) + assertEquals(FTConfig.HostType.CMS, state.hostType) + } + + @Test + fun testConnectChoiceEnum() { + val choices = ConnectChoice.values() + assertEquals(3, choices.size) + assertTrue(choices.contains(ConnectChoice.NEW_WINDOW)) + assertTrue(choices.contains(ConnectChoice.ABORT_CURRENT)) + assertTrue(choices.contains(ConnectChoice.CANCEL)) + } + + @Test + fun testHostStorageExportAndLoadIniRoundtrip() { + val host1 = SavedHost( + id = "test-h1", + name = "Test Mainframe MVS", + host = "mvs.corp.local", + port = 992, + model = 4, + dynamicRows = 62, + dynamicCols = 160, + luName = "LU01", + autoConnect = true, + hostType = "TSO", + useTls = true, + tlsVerifyCert = false, + tn3270e = true, + graphicsMode = "VECTOR_GRAPHICS", + codePage = "1047", + proxyType = "SOCKS5", + proxyHost = "proxy.corp", + proxyPort = 1080, + proxyUsername = "admin", + proxyPassword = "secret" + ) + val host2 = SavedHost( + id = "test-h2", + name = "VM CMS Guest", + host = "vm.corp.local", + port = 23, + hostType = "CMS" + ) + + val sw = java.io.StringWriter() + HostStorage.exportToIni(listOf(host1, host2), sw) + val exportedIni = sw.toString() + + assertTrue(exportedIni.contains("[Test Mainframe MVS]")) + assertTrue(exportedIni.contains("host = mvs.corp.local")) + assertTrue(exportedIni.contains("port = 992")) + assertTrue(exportedIni.contains("autoConnect = true")) + assertTrue(exportedIni.contains("hostType = TSO")) + assertTrue(exportedIni.contains("useTls = true")) + assertTrue(exportedIni.contains("[VM CMS Guest]")) + assertTrue(exportedIni.contains("hostType = CMS")) + + val loaded = HostStorage.loadFromIni(StringReader(exportedIni)) + assertEquals(2, loaded.size) + + val loadedH1 = loaded.find { it.name == "Test Mainframe MVS" } + assertNotNull(loadedH1) + assertEquals("test-h1", loadedH1!!.id) + assertEquals("mvs.corp.local", loadedH1.host) + assertEquals(992, loadedH1.port) + assertEquals(4, loadedH1.model) + assertEquals(62, loadedH1.dynamicRows) + assertEquals(160, loadedH1.dynamicCols) + assertEquals("LU01", loadedH1.luName) + assertTrue(loadedH1.autoConnect) + assertEquals("TSO", loadedH1.hostType) + assertTrue(loadedH1.useTls) + assertFalse(loadedH1.tlsVerifyCert) + assertTrue(loadedH1.tn3270e) + assertEquals("VECTOR_GRAPHICS", loadedH1.graphicsMode) + assertEquals("1047", loadedH1.codePage) + assertEquals("SOCKS5", loadedH1.proxyType) + assertEquals("proxy.corp", loadedH1.proxyHost) + assertEquals(1080, loadedH1.proxyPort) + assertEquals("admin", loadedH1.proxyUsername) + assertEquals("secret", loadedH1.proxyPassword) + + val loadedH2 = loaded.find { it.name == "VM CMS Guest" } + assertNotNull(loadedH2) + assertEquals("test-h2", loadedH2!!.id) + assertEquals("vm.corp.local", loadedH2.host) + assertEquals(23, loadedH2.port) + assertEquals("CMS", loadedH2.hostType) + } +}