From 146d462fb975900397e579f2da6407eff96361ea Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:56:03 -0400 Subject: [PATCH] fix(network): preserve verified host endpoints Persist bounded verified endpoint history across restarts and migrate legacy rows without deleting sources on read or destination-write failure. Keep UUID, certificate, and fallback identity checks fail closed. --- .../nova/computers/ComputerDatabaseManager.kt | 155 ++++- .../nova/computers/ComputerManagerService.kt | 10 +- .../nova/computers/LegacyDatabaseReader.kt | 89 +-- .../nova/computers/LegacyDatabaseReader2.kt | 48 +- .../nova/computers/LegacyDatabaseReader3.kt | 52 +- .../nova/nvstream/http/ComputerDetails.kt | 82 ++- .../com/papi/nova/nvstream/http/NvHTTP.kt | 74 +- .../KotlinComputerPersistenceMigrationTest.kt | 639 +++++++++++++++++- .../KotlinComputerServiceMigrationTest.kt | 221 ++++++ .../http/ComputerDetailsKnownAddressesTest.kt | 215 ++++++ 10 files changed, 1416 insertions(+), 169 deletions(-) create mode 100644 app/src/test/java/com/papi/nova/nvstream/http/ComputerDetailsKnownAddressesTest.kt diff --git a/app/src/main/java/com/papi/nova/computers/ComputerDatabaseManager.kt b/app/src/main/java/com/papi/nova/computers/ComputerDatabaseManager.kt index 3ef86be28..790a4a779 100644 --- a/app/src/main/java/com/papi/nova/computers/ComputerDatabaseManager.kt +++ b/app/src/main/java/com/papi/nova/computers/ComputerDatabaseManager.kt @@ -14,6 +14,7 @@ import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.LinkedList import java.util.Locale +import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -48,17 +49,44 @@ class ComputerDatabaseManager(c: Context) { ) ) - var oldComputers = LegacyDatabaseReader.migrateAllComputers(c) - for (computer in oldComputers) { - updateComputer(computer) + migrateLegacyDatabase(c, LegacyDatabaseReader.COMPUTER_DB_NAME, LegacyDatabaseReader.readAllComputers(c)) + migrateLegacyDatabase(c, LegacyDatabaseReader2.COMPUTER_DB_NAME, LegacyDatabaseReader2.readAllComputers(c)) + migrateLegacyDatabase(c, LegacyDatabaseReader3.COMPUTER_DB_NAME, LegacyDatabaseReader3.readAllComputers(c)) + } + + private fun migrateLegacyDatabase( + c: Context, + databaseName: String, + oldComputers: List?, + ) { + if (oldComputers == null) { + return } - oldComputers = LegacyDatabaseReader2.migrateAllComputers(c) - for (computer in oldComputers) { - updateComputer(computer) + + var migrationSucceeded = false + try { + computerDb.beginTransaction() + migrationSucceeded = oldComputers.all { computer -> + computer.seedLegacyAddresses() + updateComputer(computer) + } + if (migrationSucceeded) { + computerDb.setTransactionSuccessful() + } + } catch (_: RuntimeException) { + migrationSucceeded = false + } finally { + if (computerDb.inTransaction()) { + try { + computerDb.endTransaction() + } catch (_: RuntimeException) { + migrationSucceeded = false + } + } } - oldComputers = LegacyDatabaseReader3.migrateAllComputers(c) - for (computer in oldComputers) { - updateComputer(computer) + + if (migrationSucceeded) { + c.deleteDatabase(databaseName) } } @@ -67,6 +95,9 @@ class ComputerDatabaseManager(c: Context) { } fun updateComputer(details: ComputerDetails): Boolean { + if (details.uuid.isBlank()) { + return false + } val values = ContentValues() values.put(COMPUTER_UUID_COLUMN_NAME, details.uuid) values.put(COMPUTER_NAME_COLUMN_NAME, details.name) @@ -77,7 +108,12 @@ class ComputerDatabaseManager(c: Context) { addresses.put(AddressFields.REMOTE, tupleToJson(details.remoteAddress)) addresses.put(AddressFields.MANUAL, tupleToJson(details.manualAddress)) addresses.put(AddressFields.IPV6, tupleToJson(details.ipv6Address)) - values.put(ADDRESSES_COLUMN_NAME, addresses.toString()) + addresses.put(AddressFields.KNOWN, tuplesToJson(details.knownAddresses)) + val serializedAddresses = addresses.toString() + if (serializedAddresses.length > MAX_ADDRESSES_JSON_LENGTH) { + return false + } + values.put(ADDRESSES_COLUMN_NAME, serializedAddresses) } catch (e: JSONException) { throw RuntimeException(e) } @@ -90,9 +126,8 @@ class ComputerDatabaseManager(c: Context) { } else { values.put(SERVER_CERT_COLUMN_NAME, null as ByteArray?) } - } catch (e: CertificateEncodingException) { - values.put(SERVER_CERT_COLUMN_NAME, null as ByteArray?) - e.printStackTrace() + } catch (_: CertificateEncodingException) { + return false } return -1L != computerDb.insertWithOnConflict( COMPUTER_TABLE_NAME, @@ -102,32 +137,48 @@ class ComputerDatabaseManager(c: Context) { ) } - private fun getComputerFromCursor(c: Cursor): ComputerDetails { + private fun getComputerFromCursor(c: Cursor): ComputerDetails? { val details = ComputerDetails() details.uuid = c.getString(0) ?: "" + if (details.uuid.isBlank()) { + return null + } details.name = c.getString(1) ?: "" + val serializedAddresses = c.getString(2) ?: return null + if (serializedAddresses.length > MAX_ADDRESSES_JSON_LENGTH) { + return null + } try { - val addresses = JSONObject(c.getString(2)) + val addresses = JSONObject(serializedAddresses) details.localAddress = tupleFromJson(addresses, AddressFields.LOCAL) details.remoteAddress = tupleFromJson(addresses, AddressFields.REMOTE) details.manualAddress = tupleFromJson(addresses, AddressFields.MANUAL) details.ipv6Address = tupleFromJson(addresses, AddressFields.IPV6) - } catch (e: JSONException) { - throw RuntimeException(e) + if (addresses.has(AddressFields.KNOWN)) { + for (knownAddress in tuplesFromJson(addresses, AddressFields.KNOWN)) { + details.rememberAddress(knownAddress) + } + } else { + details.seedLegacyAddresses() + } + } catch (_: JSONException) { + return null + } catch (_: IllegalArgumentException) { + return null } details.externalPort = details.remoteAddress?.port ?: NvHTTP.DEFAULT_HTTP_PORT details.macAddress = c.getString(3) - try { - val derCertData = c.getBlob(4) - if (derCertData != null) { + val derCertData = c.getBlob(4) + if (derCertData != null) { + try { details.serverCert = CertificateFactory.getInstance("X.509") .generateCertificate(ByteArrayInputStream(derCertData)) as X509Certificate + } catch (_: CertificateException) { + return null } - } catch (e: CertificateException) { - e.printStackTrace() } details.state = ComputerDetails.State.UNKNOWN @@ -136,10 +187,18 @@ class ComputerDatabaseManager(c: Context) { } fun getAllComputers(): List { - computerDb.rawQuery("SELECT * FROM $COMPUTER_TABLE_NAME", null).use { c -> + computerDb.query( + COMPUTER_TABLE_NAME, + null, + BOUNDED_ADDRESSES_SELECTION, + arrayOf(MAX_ADDRESSES_JSON_LENGTH.toString()), + null, + null, + null + ).use { c -> val computerList = LinkedList() while (c.moveToNext()) { - computerList.add(getComputerFromCursor(c)) + getComputerFromCursor(c)?.let(computerList::add) } return computerList } @@ -149,17 +208,16 @@ class ComputerDatabaseManager(c: Context) { computerDb.query( COMPUTER_TABLE_NAME, null, - "$COMPUTER_NAME_COLUMN_NAME=?", - arrayOf(name), + "$COMPUTER_NAME_COLUMN_NAME=? AND $BOUNDED_ADDRESSES_SELECTION", + arrayOf(name, MAX_ADDRESSES_JSON_LENGTH.toString()), null, null, null ).use { c -> - if (!c.moveToFirst()) { - return null + while (c.moveToNext()) { + getComputerFromCursor(c)?.let { return it } } - - return getComputerFromCursor(c) + return null } } @@ -167,8 +225,8 @@ class ComputerDatabaseManager(c: Context) { computerDb.query( COMPUTER_TABLE_NAME, null, - "$COMPUTER_UUID_COLUMN_NAME=?", - arrayOf(uuid), + "$COMPUTER_UUID_COLUMN_NAME=? AND $BOUNDED_ADDRESSES_SELECTION", + arrayOf(uuid, MAX_ADDRESSES_JSON_LENGTH.toString()), null, null, null @@ -186,6 +244,7 @@ class ComputerDatabaseManager(c: Context) { const val REMOTE = "remote" const val MANUAL = "manual" const val IPV6 = "ipv6" + const val KNOWN = "known" const val ADDRESS = "address" const val PORT = "port" } @@ -198,6 +257,8 @@ class ComputerDatabaseManager(c: Context) { private const val ADDRESSES_COLUMN_NAME = "Addresses" private const val MAC_ADDRESS_COLUMN_NAME = "MacAddress" private const val SERVER_CERT_COLUMN_NAME = "ServerCert" + private const val MAX_ADDRESSES_JSON_LENGTH = 64 * 1024 + private const val BOUNDED_ADDRESSES_SELECTION = "length(Addresses) <= ?" @JvmStatic @Throws(JSONException::class) @@ -213,6 +274,36 @@ class ComputerDatabaseManager(c: Context) { return json } + private fun tuplesToJson(tuples: List): JSONArray { + val json = JSONArray() + for (tuple in tuples) { + json.put(tupleToJson(tuple)) + } + return json + } + + private fun tuplesFromJson(json: JSONObject, name: String): List { + val tupleArray = json.optJSONArray(name) ?: return emptyList() + val firstIndex = maxOf(0, tupleArray.length() - ComputerDetails.MAX_KNOWN_ADDRESSES) + val tuples = ArrayList(tupleArray.length() - firstIndex) + for (index in firstIndex until tupleArray.length()) { + val tuple = tupleArray.optJSONObject(index) ?: continue + try { + tuples.add( + ComputerDetails.AddressTuple( + tuple.getString(AddressFields.ADDRESS), + tuple.getInt(AddressFields.PORT) + ) + ) + } catch (_: JSONException) { + // Ignore a malformed remembered route without losing the computer record. + } catch (_: IllegalArgumentException) { + // Ignore invalid addresses and ports from an older or damaged record. + } + } + return tuples + } + @JvmStatic @Throws(JSONException::class) fun tupleFromJson(json: JSONObject, name: String): ComputerDetails.AddressTuple? { diff --git a/app/src/main/java/com/papi/nova/computers/ComputerManagerService.kt b/app/src/main/java/com/papi/nova/computers/ComputerManagerService.kt index 51cc9749a..672475d36 100644 --- a/app/src/main/java/com/papi/nova/computers/ComputerManagerService.kt +++ b/app/src/main/java/com/papi/nova/computers/ComputerManagerService.kt @@ -637,7 +637,7 @@ class ComputerManagerService : Service() { } private fun isExpectedComputerUuid(expectedUuid: String?, returnedUuid: String?): Boolean { - return returnedUuid != null && (expectedUuid.isNullOrEmpty() || expectedUuid == returnedUuid) + return !returnedUuid.isNullOrBlank() && (expectedUuid.isNullOrEmpty() || expectedUuid == returnedUuid) } private class ParallelPollTuple( @@ -731,7 +731,7 @@ class ComputerManagerService : Service() { LimeLog.info("Parallel poll for " + details.name + " returned address: " + details.activeAddress) return if (polledDetails != null) { - details.update(polledDetails) + details.updateFromVerifiedPoll(polledDetails) true } else { false @@ -1003,6 +1003,12 @@ class ComputerManagerService : Service() { addUnique(details.manualAddress) addUnique(details.remoteAddress) addUnique(details.ipv6Address) + for (knownAddress in details.knownAddresses.asReversed()) { + addUnique(knownAddress) + if (knownAddress.port != NvHTTP.DEFAULT_HTTP_PORT) { + addUnique(ComputerDetails.AddressTuple(knownAddress.address, NvHTTP.DEFAULT_HTTP_PORT)) + } + } return addresses } diff --git a/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader.kt b/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader.kt index eec741af3..d5f13cc29 100644 --- a/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader.kt +++ b/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader.kt @@ -13,58 +13,64 @@ import java.util.LinkedList class LegacyDatabaseReader private constructor() { companion object { - private const val COMPUTER_DB_NAME = "computers.db" + internal const val COMPUTER_DB_NAME = "computers.db" private const val COMPUTER_TABLE_NAME = "Computers" private const val ADDRESS_PREFIX = "ADDRESS_PREFIX__" private fun getComputerFromCursor(c: Cursor): ComputerDetails? { - val uuid = c.getString(1) ?: return null - val details = ComputerDetails() + val uuid = c.getString(1)?.takeIf { it.isNotBlank() } ?: return null + return try { + val details = ComputerDetails() - details.name = c.getString(0) ?: "" - details.uuid = uuid + details.name = c.getString(0) ?: "" + details.uuid = uuid - try { - details.localAddress = ComputerDetails.AddressTuple( - InetAddress.getByAddress(c.getBlob(2)).hostAddress, - NvHTTP.DEFAULT_HTTP_PORT - ) - LimeLog.warning("DB: Legacy local address for " + details.name) - } catch (_: UnknownHostException) { - val stringData = c.getString(2) - if (stringData != null && stringData.startsWith(ADDRESS_PREFIX)) { + try { details.localAddress = ComputerDetails.AddressTuple( - stringData.substring(ADDRESS_PREFIX.length), + InetAddress.getByAddress(c.getBlob(2)).hostAddress, NvHTTP.DEFAULT_HTTP_PORT ) - } else { - LimeLog.severe("DB: Corrupted local address for " + details.name) + LimeLog.warning("DB: Legacy local address for " + details.name) + } catch (_: UnknownHostException) { + val stringData = c.getString(2) + if (stringData != null && stringData.startsWith(ADDRESS_PREFIX)) { + details.localAddress = ComputerDetails.AddressTuple( + stringData.substring(ADDRESS_PREFIX.length), + NvHTTP.DEFAULT_HTTP_PORT + ) + } else { + LimeLog.severe("DB: Corrupted local address for " + details.name) + } } - } - try { - details.remoteAddress = ComputerDetails.AddressTuple( - InetAddress.getByAddress(c.getBlob(3)).hostAddress, - NvHTTP.DEFAULT_HTTP_PORT - ) - LimeLog.warning("DB: Legacy remote address for " + details.name) - } catch (_: UnknownHostException) { - val stringData = c.getString(3) - if (stringData != null && stringData.startsWith(ADDRESS_PREFIX)) { + try { details.remoteAddress = ComputerDetails.AddressTuple( - stringData.substring(ADDRESS_PREFIX.length), + InetAddress.getByAddress(c.getBlob(3)).hostAddress, NvHTTP.DEFAULT_HTTP_PORT ) - } else { - LimeLog.severe("DB: Corrupted remote address for " + details.name) + LimeLog.warning("DB: Legacy remote address for " + details.name) + } catch (_: UnknownHostException) { + val stringData = c.getString(3) + if (stringData != null && stringData.startsWith(ADDRESS_PREFIX)) { + details.remoteAddress = ComputerDetails.AddressTuple( + stringData.substring(ADDRESS_PREFIX.length), + NvHTTP.DEFAULT_HTTP_PORT + ) + } else { + LimeLog.severe("DB: Corrupted remote address for " + details.name) + } } - } - details.manualAddress = details.remoteAddress - details.macAddress = c.getString(4) - details.state = ComputerDetails.State.UNKNOWN + details.manualAddress = details.remoteAddress + details.macAddress = c.getString(4) + details.state = ComputerDetails.State.UNKNOWN - return details + details + } catch (_: IllegalArgumentException) { + null + } catch (_: SQLiteException) { + null + } } private fun getAllComputers(db: SQLiteDatabase): List { @@ -78,8 +84,7 @@ class LegacyDatabaseReader private constructor() { } } - @JvmStatic - fun migrateAllComputers(c: Context): List { + internal fun readAllComputers(c: Context): List? { return try { SQLiteDatabase.openDatabase( c.getDatabasePath(COMPUTER_DB_NAME).path, @@ -88,11 +93,13 @@ class LegacyDatabaseReader private constructor() { ).use { computerDb -> getAllComputers(computerDb) } - } catch (_: SQLiteException) { - LinkedList() - } finally { - c.deleteDatabase(COMPUTER_DB_NAME) + } catch (_: RuntimeException) { + null } } + + @JvmStatic + fun migrateAllComputers(c: Context): List = + readAllComputers(c) ?: LinkedList() } } diff --git a/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader2.kt b/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader2.kt index 604877aab..251854777 100644 --- a/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader2.kt +++ b/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader2.kt @@ -14,35 +14,38 @@ import java.util.LinkedList class LegacyDatabaseReader2 private constructor() { companion object { - private const val COMPUTER_DB_NAME = "computers2.db" + internal const val COMPUTER_DB_NAME = "computers2.db" private const val COMPUTER_TABLE_NAME = "Computers" private fun getComputerFromCursor(c: Cursor): ComputerDetails? { val uuid = c.getString(0) ?: return null - val details = ComputerDetails() - - details.uuid = uuid - details.name = c.getString(1) ?: "" - details.localAddress = ComputerDetails.AddressTuple(c.getString(2), NvHTTP.DEFAULT_HTTP_PORT) - details.remoteAddress = ComputerDetails.AddressTuple(c.getString(3), NvHTTP.DEFAULT_HTTP_PORT) - details.manualAddress = ComputerDetails.AddressTuple(c.getString(4), NvHTTP.DEFAULT_HTTP_PORT) - details.macAddress = c.getString(5) + if (uuid.isBlank()) { + return null + } + return try { + val details = ComputerDetails() + details.uuid = uuid + details.name = c.getString(1) ?: "" + details.localAddress = ComputerDetails.AddressTuple(c.getString(2), NvHTTP.DEFAULT_HTTP_PORT) + details.remoteAddress = ComputerDetails.AddressTuple(c.getString(3), NvHTTP.DEFAULT_HTTP_PORT) + details.manualAddress = ComputerDetails.AddressTuple(c.getString(4), NvHTTP.DEFAULT_HTTP_PORT) + details.macAddress = c.getString(5) - if (c.columnCount >= 7) { - try { + if (c.columnCount >= 7) { val derCertData = c.getBlob(6) if (derCertData != null) { details.serverCert = CertificateFactory.getInstance("X.509") .generateCertificate(ByteArrayInputStream(derCertData)) as X509Certificate } - } catch (e: CertificateException) { - e.printStackTrace() } - } - - details.state = ComputerDetails.State.UNKNOWN - return details + details.state = ComputerDetails.State.UNKNOWN + details + } catch (_: CertificateException) { + null + } catch (_: RuntimeException) { + null + } } @JvmStatic @@ -57,8 +60,7 @@ class LegacyDatabaseReader2 private constructor() { } } - @JvmStatic - fun migrateAllComputers(c: Context): List { + internal fun readAllComputers(c: Context): List? { return try { SQLiteDatabase.openDatabase( c.getDatabasePath(COMPUTER_DB_NAME).path, @@ -68,10 +70,12 @@ class LegacyDatabaseReader2 private constructor() { getAllComputers(computerDb) } } catch (_: SQLiteException) { - LinkedList() - } finally { - c.deleteDatabase(COMPUTER_DB_NAME) + null } } + + @JvmStatic + fun migrateAllComputers(c: Context): List = + readAllComputers(c) ?: LinkedList() } } diff --git a/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader3.kt b/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader3.kt index 34f5d24d2..328eb56ae 100644 --- a/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader3.kt +++ b/app/src/main/java/com/papi/nova/computers/LegacyDatabaseReader3.kt @@ -14,7 +14,7 @@ import java.util.LinkedList class LegacyDatabaseReader3 private constructor() { companion object { - private const val COMPUTER_DB_NAME = "computers3.db" + internal const val COMPUTER_DB_NAME = "computers3.db" private const val COMPUTER_TABLE_NAME = "Computers" private const val ADDRESS_DELIMITER = ';' private const val PORT_DELIMITER = '_' @@ -40,33 +40,36 @@ class LegacyDatabaseReader3 private constructor() { private fun getComputerFromCursor(c: Cursor): ComputerDetails? { val uuid = c.getString(0) ?: return null - val details = ComputerDetails() - - details.uuid = uuid - details.name = c.getString(1) ?: "" + if (uuid.isBlank()) { + return null + } + return try { + val details = ComputerDetails() + details.uuid = uuid + details.name = c.getString(1) ?: "" - val addresses = splitPreservingEmptyParts(c.getString(2), ADDRESS_DELIMITER) - details.localAddress = splitAddressToTuple(readNonEmptyString(addresses[0])) - details.remoteAddress = splitAddressToTuple(readNonEmptyString(addresses[1])) - details.manualAddress = splitAddressToTuple(readNonEmptyString(addresses[2])) - details.ipv6Address = splitAddressToTuple(readNonEmptyString(addresses[3])) + val addresses = splitPreservingEmptyParts(c.getString(2), ADDRESS_DELIMITER) + details.localAddress = splitAddressToTuple(readNonEmptyString(addresses[0])) + details.remoteAddress = splitAddressToTuple(readNonEmptyString(addresses[1])) + details.manualAddress = splitAddressToTuple(readNonEmptyString(addresses[2])) + details.ipv6Address = splitAddressToTuple(readNonEmptyString(addresses[3])) - details.externalPort = details.remoteAddress?.port ?: NvHTTP.DEFAULT_HTTP_PORT - details.macAddress = c.getString(3) + details.externalPort = details.remoteAddress?.port ?: NvHTTP.DEFAULT_HTTP_PORT + details.macAddress = c.getString(3) - try { val derCertData = c.getBlob(4) if (derCertData != null) { details.serverCert = CertificateFactory.getInstance("X.509") .generateCertificate(ByteArrayInputStream(derCertData)) as X509Certificate } - } catch (e: CertificateException) { - e.printStackTrace() - } - details.state = ComputerDetails.State.UNKNOWN - - return details + details.state = ComputerDetails.State.UNKNOWN + details + } catch (_: CertificateException) { + null + } catch (_: RuntimeException) { + null + } } @JvmStatic @@ -81,8 +84,7 @@ class LegacyDatabaseReader3 private constructor() { } } - @JvmStatic - fun migrateAllComputers(c: Context): List { + internal fun readAllComputers(c: Context): List? { return try { SQLiteDatabase.openDatabase( c.getDatabasePath(COMPUTER_DB_NAME).path, @@ -92,12 +94,14 @@ class LegacyDatabaseReader3 private constructor() { getAllComputers(computerDb) } } catch (_: SQLiteException) { - LinkedList() - } finally { - c.deleteDatabase(COMPUTER_DB_NAME) + null } } + @JvmStatic + fun migrateAllComputers(c: Context): List = + readAllComputers(c) ?: LinkedList() + private fun splitPreservingEmptyParts(input: String, delimiter: Char): List { val parts = ArrayList() var start = 0 diff --git a/app/src/main/java/com/papi/nova/nvstream/http/ComputerDetails.kt b/app/src/main/java/com/papi/nova/nvstream/http/ComputerDetails.kt index 27a8f13e9..7e0f5e109 100644 --- a/app/src/main/java/com/papi/nova/nvstream/http/ComputerDetails.kt +++ b/app/src/main/java/com/papi/nova/nvstream/http/ComputerDetails.kt @@ -1,6 +1,7 @@ package com.papi.nova.nvstream.http import java.security.cert.X509Certificate +import java.util.Locale import java.util.Objects class ComputerDetails { @@ -27,16 +28,33 @@ class ComputerDetails { if (address == null) { throw IllegalArgumentException("Address cannot be null") } - if (port <= 0) { + if (port !in 1..65535) { throw IllegalArgumentException("Invalid port") } - // If this was an escaped IPv6 address, remove the brackets - this.address = if (address.startsWith("[") && address.endsWith("]")) { - address.substring(1, address.length - 1) + var normalizedAddress = address.trim() + // If this was an escaped IPv6 address, remove the brackets. + if (normalizedAddress.startsWith("[") && normalizedAddress.endsWith("]")) { + normalizedAddress = normalizedAddress.substring(1, normalizedAddress.length - 1) + } + // DNS names are case-insensitive and a trailing root label is optional. IPv6 + // hexadecimal digits are case-insensitive too, but preserve a case-sensitive + // scope identifier if one is present. + val canonicalAddress = if (normalizedAddress.contains(":")) { + val scopeIndex = normalizedAddress.indexOf("%") + if (scopeIndex >= 0) { + normalizedAddress.substring(0, scopeIndex).lowercase(Locale.ROOT) + + normalizedAddress.substring(scopeIndex) + } else { + normalizedAddress.lowercase(Locale.ROOT) + } } else { - address + normalizedAddress.removeSuffix(".").lowercase(Locale.ROOT) + } + if (canonicalAddress.isBlank()) { + throw IllegalArgumentException("Address cannot be blank") } + this.address = canonicalAddress this.port = port } @@ -68,6 +86,12 @@ class ComputerDetails { @JvmField var remoteAddress: AddressTuple? = null @JvmField var manualAddress: AddressTuple? = null @JvmField var ipv6Address: AddressTuple? = null + private val knownAddressesLock = Any() + private val mutableKnownAddresses = ArrayList() + val knownAddresses: List + get() = synchronized(knownAddressesLock) { + mutableKnownAddresses.map { AddressTuple(it.address, it.port) } + } @JvmField var macAddress: String? = null @JvmField var serverCert: X509Certificate? = null @@ -103,6 +127,35 @@ class ComputerDetails { update(details) } + fun rememberAddress(address: AddressTuple?) { + if (address == null) { + return + } + + val rememberedAddress = AddressTuple(address.address, address.port) + synchronized(knownAddressesLock) { + mutableKnownAddresses.remove(rememberedAddress) + mutableKnownAddresses.add(rememberedAddress) + while (mutableKnownAddresses.size > MAX_KNOWN_ADDRESSES) { + mutableKnownAddresses.removeAt(0) + } + } + } + + internal fun seedLegacyAddresses() { + rememberAddress(localAddress) + rememberAddress(remoteAddress) + rememberAddress(manualAddress) + rememberAddress(ipv6Address) + rememberAddress(activeAddress) + } + + private fun rememberAddressesFrom(details: ComputerDetails) { + for (address in details.knownAddresses) { + rememberAddress(address) + } + } + fun guessExternalPort(): Int { return if (externalPort != 0) { externalPort @@ -120,6 +173,11 @@ class ComputerDetails { } fun update(details: ComputerDetails) { + if (uuid.isNotEmpty() && uuid != details.uuid) { + throw IllegalArgumentException("Cannot merge details for a different computer UUID") + } + rememberAddressesFrom(details) + state = details.state name = details.name uuid = details.uuid @@ -176,6 +234,20 @@ class ComputerDetails { serverCommands = details.serverCommands } + fun updateFromVerifiedPoll(details: ComputerDetails) { + if (details.uuid.isBlank()) { + throw IllegalArgumentException("A verified poll must include a computer UUID") + } + val verifiedAddress = details.activeAddress + ?: throw IllegalArgumentException("A verified poll must include its active address") + update(details) + rememberAddress(verifiedAddress) + } + + companion object { + const val MAX_KNOWN_ADDRESSES = 16 + } + override fun toString(): String { /* * Permissions: diff --git a/app/src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt b/app/src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt index 923cf3b6c..86f686131 100644 --- a/app/src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt +++ b/app/src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt @@ -45,7 +45,6 @@ import javax.net.ssl.HostnameVerifier import javax.net.ssl.HttpsURLConnection import javax.net.ssl.KeyManager import javax.net.ssl.SSLContext -import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSession import javax.net.ssl.TrustManager @@ -148,36 +147,13 @@ class NvHTTP @Throws(IOException::class) constructor( } defaultTrustManager = getDefaultTrustManager() - trustManager = object : X509TrustManager { - override fun getAcceptedIssuers(): Array { - return emptyArray() - } - - override fun checkClientTrusted(certs: Array, authType: String) { - throw IllegalStateException("Should never be called") - } - - @Throws(CertificateException::class) - override fun checkServerTrusted(certs: Array, authType: String) { - try { - defaultTrustManager.checkServerTrusted(certs, authType) - } catch (e: CertificateException) { - val pinnedCert = serverCert - if (certs.size == 1 && pinnedCert != null) { - if (certs[0] != pinnedCert) { - throw CertificateException("Certificate mismatch") - } - } else { - throw e - } - } - } - } + trustManager = createServerTrustManager(defaultTrustManager) { serverCert } val hv = HostnameVerifier { hostname: String, session: SSLSession -> try { val certificates: Array = session.peerCertificates - if (certificates.size == 1 && certificates[0] == serverCert) { + val pinnedCert = serverCert + if (pinnedCert != null && certificates.firstOrNull() == pinnedCert) { return@HostnameVerifier true } } catch (e: SSLPeerUnverifiedException) { @@ -225,20 +201,11 @@ class NvHTTP @Throws(IOException::class) constructor( if (serverCert != null) { try { - val resp = try { - openHttpConnectionToString(client, getHttpsUrl(likelyOnline), "serverinfo") - } catch (e: SSLHandshakeException) { - if (e.cause is CertificateException) { - throw HostHttpResponseException(401, "Server certificate mismatch") - } else { - throw e - } - } - + val resp = openHttpConnectionToString(client, getHttpsUrl(likelyOnline), "serverinfo") getServerVersion(resp) return resp - } catch (e: HostHttpResponseException) { - if (e.getErrorCode() == 401) { + } catch (e: IOException) { + if (isServerInfoHttpFallbackAllowed(e)) { return openHttpConnectionToString(client, baseUrlHttp, "serverinfo") } throw e @@ -779,6 +746,35 @@ class NvHTTP @Throws(IOException::class) constructor( } companion object { + @JvmStatic + internal fun isServerInfoHttpFallbackAllowed(error: IOException): Boolean = + error is HostHttpResponseException && error.getErrorCode() == 401 + + @JvmStatic + internal fun createServerTrustManager( + defaultTrustManager: X509TrustManager, + serverCertProvider: () -> X509Certificate?, + ): X509TrustManager = object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + + override fun checkClientTrusted(certs: Array, authType: String) { + throw IllegalStateException("Should never be called") + } + + @Throws(CertificateException::class) + override fun checkServerTrusted(certs: Array, authType: String) { + val pinnedCert = serverCertProvider() + if (pinnedCert != null) { + if (certs.firstOrNull() != pinnedCert) { + throw CertificateException("Certificate mismatch") + } + return + } + + defaultTrustManager.checkServerTrusted(certs, authType) + } + } + private const val DEFAULT_HTTPS_PORT = 47984 const val DEFAULT_HTTP_PORT = 47989 const val SHORT_CONNECTION_TIMEOUT = 3000 diff --git a/app/src/test/java/com/papi/nova/computers/KotlinComputerPersistenceMigrationTest.kt b/app/src/test/java/com/papi/nova/computers/KotlinComputerPersistenceMigrationTest.kt index ceaa5bac3..54dfd7aad 100644 --- a/app/src/test/java/com/papi/nova/computers/KotlinComputerPersistenceMigrationTest.kt +++ b/app/src/test/java/com/papi/nova/computers/KotlinComputerPersistenceMigrationTest.kt @@ -7,6 +7,9 @@ import androidx.test.core.app.ApplicationProvider import com.papi.nova.nvstream.http.ComputerDetails import com.papi.nova.nvstream.http.NvHTTP import java.io.File +import java.security.cert.CertificateEncodingException +import java.security.cert.X509Certificate +import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -107,6 +110,8 @@ class KotlinComputerPersistenceMigrationTest { details.remoteAddress = ComputerDetails.AddressTuple("wan.example.test", 48010) details.manualAddress = ComputerDetails.AddressTuple("manual.example.test", 48011) details.ipv6Address = ComputerDetails.AddressTuple("2001:db8::4", 48012) + details.rememberAddress(ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989)) + details.rememberAddress(ComputerDetails.AddressTuple("100.100.20.30", 47989)) details.macAddress = "AA:BB:CC:DD:EE:FF" assertTrue(manager.updateComputer(details)) @@ -120,6 +125,8 @@ class KotlinComputerPersistenceMigrationTest { assertEquals(48010, byUuid.externalPort) assertEquals("manual.example.test", byUuid.manualAddress!!.address) assertEquals("2001:db8::4", byUuid.ipv6Address!!.address) + assertTrue(byUuid.knownAddresses.contains(ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989))) + assertTrue(byUuid.knownAddresses.contains(ComputerDetails.AddressTuple("100.100.20.30", 47989))) assertEquals("AA:BB:CC:DD:EE:FF", byUuid.macAddress) assertEquals(ComputerDetails.State.UNKNOWN, byUuid.state) @@ -136,11 +143,621 @@ class KotlinComputerPersistenceMigrationTest { } @Test - fun legacyDatabaseReader2MigratesRowsSkipsNullUuidAndDeletesOldDatabase() { + fun blankUuidCannotBePersisted() { + val manager = ComputerDatabaseManager(context) + try { + val invalid = ComputerDetails().apply { + name = "Missing Identity" + rememberAddress(ComputerDetails.AddressTuple("route.example.test", 47989)) + } + + assertFalse(manager.updateComputer(invalid)) + assertEquals(emptyList(), manager.getAllComputers()) + } finally { + manager.close() + } + } + + @Test + fun certificateEncodingFailurePreservesTheLastGoodRow() { + val manager = ComputerDatabaseManager(context) + try { + val lastGood = ComputerDetails().apply { + uuid = "uuid-cert-write" + name = "Last Good" + rememberAddress(ComputerDetails.AddressTuple("verified.example.test", 47989)) + } + assertTrue(manager.updateComputer(lastGood)) + + val invalidReplacement = ComputerDetails(lastGood).apply { + name = "Must Not Replace" + val certificate = org.mockito.Mockito.mock(X509Certificate::class.java) + org.mockito.Mockito.`when`(certificate.encoded) + .thenThrow(CertificateEncodingException("synthetic encoding failure")) + serverCert = certificate + } + + assertFalse(manager.updateComputer(invalidReplacement)) + assertEquals("Last Good", manager.getComputerByUUID(lastGood.uuid)!!.name) + } finally { + manager.close() + } + } + + @Test + fun currentSchemaPersistsOnlyExplicitRememberedHistory() { + val manager = ComputerDatabaseManager(context) + try { + val details = ComputerDetails().apply { + uuid = "uuid-verified-only" + name = "Verified Only" + localAddress = ComputerDetails.AddressTuple("advertised-lan.example.test", 47989) + remoteAddress = ComputerDetails.AddressTuple("advertised-wan.example.test", 47989) + rememberAddress(ComputerDetails.AddressTuple("verified.example.test", 47989)) + } + + assertTrue(manager.updateComputer(details)) + + val restored = manager.getComputerByUUID(details.uuid) + assertNotNull(restored) + assertEquals( + listOf(ComputerDetails.AddressTuple("verified.example.test", 47989)), + restored!!.knownAddresses + ) + } finally { + manager.close() + } + } + + @Test + fun currentDatabaseWithoutKnownAddressesSeedsLegacyRoutes() { + ComputerDatabaseManager(context).close() + val db = context.openOrCreateDatabase("computers4.db", 0, null) + val addresses = JSONObject().apply { + put("local", ComputerDatabaseManager.tupleToJson(ComputerDetails.AddressTuple("192.168.1.25", 47989))) + put("manual", ComputerDatabaseManager.tupleToJson(ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989))) + } + val values = ContentValues().apply { + put("UUID", "uuid-legacy-current") + put("ComputerName", "Legacy Current") + put("Addresses", addresses.toString()) + } + db.insert("Computers", null, values) + db.close() + + val manager = ComputerDatabaseManager(context) + try { + val restored = manager.getComputerByUUID("uuid-legacy-current") + assertNotNull(restored) + assertEquals( + listOf( + ComputerDetails.AddressTuple("192.168.1.25", 47989), + ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989) + ), + restored!!.knownAddresses + ) + } finally { + manager.close() + } + } + + @Test + fun malformedRememberedRoutesDoNotHideLegacyOrValidCandidates() { + ComputerDatabaseManager(context).close() + val db = context.openOrCreateDatabase("computers4.db", 0, null) + val addresses = JSONObject().apply { + put("local", ComputerDatabaseManager.tupleToJson(ComputerDetails.AddressTuple("192.168.1.25", 47989))) + put( + "known", + JSONArray() + .put(JSONObject().put("address", "").put("port", 47989)) + .put("not-an-endpoint") + .put(JSONObject().put("address", "invalid-port.example.test").put("port", 65536)) + .put(JSONObject().put("address", "pc-papi.tailnet.ts.net").put("port", 47989)) + ) + } + val values = ContentValues().apply { + put("UUID", "uuid-damaged-known") + put("ComputerName", "Damaged Known Routes") + put("Addresses", addresses.toString()) + } + db.insert("Computers", null, values) + db.close() + + val manager = ComputerDatabaseManager(context) + try { + val restored = manager.getComputerByUUID("uuid-damaged-known") + assertNotNull(restored) + assertEquals(ComputerDetails.AddressTuple("192.168.1.25", 47989), restored!!.localAddress) + assertEquals( + listOf(ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989)), + restored.knownAddresses + ) + } finally { + manager.close() + } + } + + @Test + fun malformedCertificateRowIsSkippedWithoutHidingValidRows() { + val manager = ComputerDatabaseManager(context) + try { + val valid = ComputerDetails().apply { + uuid = "uuid-valid-row" + name = "Valid Row" + rememberAddress(ComputerDetails.AddressTuple("valid.example.test", 47989)) + } + assertTrue(manager.updateComputer(valid)) + } finally { + manager.close() + } + + val db = context.openOrCreateDatabase("computers4.db", 0, null) + val addresses = JSONObject().apply { + put("known", JSONArray().put(JSONObject().put("address", "damaged.example.test").put("port", 47989))) + } + val values = ContentValues().apply { + put("UUID", "uuid-damaged-cert") + put("ComputerName", "Damaged Certificate") + put("Addresses", addresses.toString()) + put("ServerCert", byteArrayOf(1, 2, 3, 4)) + } + db.insert("Computers", null, values) + db.close() + + val restoredManager = ComputerDatabaseManager(context) + try { + assertNull(restoredManager.getComputerByUUID("uuid-damaged-cert")) + assertEquals(listOf("uuid-valid-row"), restoredManager.getAllComputers().map { it.uuid }) + } finally { + restoredManager.close() + } + } + + @Test + fun blankUuidDatabaseRowIsSkippedWithoutHidingValidRows() { + val manager = ComputerDatabaseManager(context) + try { + val valid = ComputerDetails().apply { + uuid = "uuid-valid-identity" + name = "Valid Identity" + rememberAddress(ComputerDetails.AddressTuple("valid.example.test", 47989)) + } + assertTrue(manager.updateComputer(valid)) + } finally { + manager.close() + } + + val db = context.openOrCreateDatabase("computers4.db", 0, null) + val values = ContentValues().apply { + put("UUID", "") + put("ComputerName", "Missing Identity") + put( + "Addresses", + JSONObject() + .put("known", JSONArray().put(JSONObject().put("address", "route.example.test").put("port", 47989))) + .toString() + ) + } + db.insert("Computers", null, values) + db.close() + + val restoredManager = ComputerDatabaseManager(context) + try { + assertNull(restoredManager.getComputerByUUID("")) + assertEquals(listOf("uuid-valid-identity"), restoredManager.getAllComputers().map { it.uuid }) + } finally { + restoredManager.close() + } + } + + @Test + fun oversizedRememberedHistoryKeepsOnlyTheNewestBoundedRoutes() { + ComputerDatabaseManager(context).close() + val db = context.openOrCreateDatabase("computers4.db", 0, null) + val known = JSONArray() + repeat(ComputerDetails.MAX_KNOWN_ADDRESSES + 2) { index -> + known.put( + JSONObject() + .put("address", "route-${index + 1}.example.test") + .put("port", 47989) + ) + } + val values = ContentValues().apply { + put("UUID", "uuid-oversized-history") + put("ComputerName", "Oversized History") + put("Addresses", JSONObject().put("known", known).toString()) + } + db.insert("Computers", null, values) + db.close() + + val manager = ComputerDatabaseManager(context) + try { + val restored = manager.getComputerByUUID("uuid-oversized-history")!! + assertEquals(ComputerDetails.MAX_KNOWN_ADDRESSES, restored.knownAddresses.size) + assertEquals("route-3.example.test", restored.knownAddresses.first().address) + assertEquals( + "route-${ComputerDetails.MAX_KNOWN_ADDRESSES + 2}.example.test", + restored.knownAddresses.last().address + ) + } finally { + manager.close() + } + } + + @Test + fun malformedPrimaryAddressRowIsSkippedWithoutHidingValidRows() { + val manager = ComputerDatabaseManager(context) + try { + val valid = ComputerDetails().apply { + uuid = "uuid-valid-address-row" + name = "Valid Address Row" + rememberAddress(ComputerDetails.AddressTuple("valid.example.test", 47989)) + } + assertTrue(manager.updateComputer(valid)) + } finally { + manager.close() + } + + val db = context.openOrCreateDatabase("computers4.db", 0, null) + val malformedAddresses = JSONObject().apply { + put("local", JSONObject().put("address", "broken.example.test").put("port", 65536)) + } + val values = ContentValues().apply { + put("UUID", "uuid-malformed-address") + put("ComputerName", "Malformed Address") + put("Addresses", malformedAddresses.toString()) + } + db.insert("Computers", null, values) + db.close() + + val restoredManager = ComputerDatabaseManager(context) + try { + assertNull(restoredManager.getComputerByUUID("uuid-malformed-address")) + assertEquals( + listOf("uuid-valid-address-row"), + restoredManager.getAllComputers().map { it.uuid } + ) + } finally { + restoredManager.close() + } + } + + @Test + fun oversizedAddressRowsAreFilteredBySqlBeforeCursorRestoration() { + val source = File("src/main/java/com/papi/nova/computers/ComputerDatabaseManager.kt").readText() + assertTrue( + source.contains( + "private const val BOUNDED_ADDRESSES_SELECTION = \"length(Addresses) <= ?\"" + ) + ) + + fun queryBody(startMarker: String, endMarker: String): String { + val start = source.indexOf(startMarker) + val end = source.indexOf(endMarker, start + 1) + assertTrue("Expected bounded database query $startMarker", start >= 0 && end > start) + return source.substring(start, end) + } + + val getAllBody = queryBody("fun getAllComputers()", "fun getComputerByName(") + val getByNameBody = queryBody("fun getComputerByName(", "fun getComputerByUUID(") + val getByUuidBody = queryBody("fun getComputerByUUID(", "private object AddressFields") + + for (body in listOf(getAllBody, getByNameBody, getByUuidBody)) { + val sqlFilter = body.indexOf("BOUNDED_ADDRESSES_SELECTION") + val sizeArgument = body.indexOf("MAX_ADDRESSES_JSON_LENGTH.toString()") + val cursorRestoration = body.indexOf("getComputerFromCursor") + assertTrue("Oversized address JSON must be filtered in SQL", sqlFilter >= 0) + assertTrue("The SQL size filter must receive its bound", sizeArgument > sqlFilter) + assertTrue("SQL filtering must precede cursor restoration", cursorRestoration > sizeArgument) + } + } + + @Test + fun oversizedAddressJsonRowsAreExcludedBeforeRestoration() { + val manager = ComputerDatabaseManager(context) + try { + val valid = ComputerDetails().apply { + uuid = "uuid-bounded-addresses" + name = "Bounded Addresses" + rememberAddress(ComputerDetails.AddressTuple("valid.example.test", 47989)) + } + assertTrue(manager.updateComputer(valid)) + } finally { + manager.close() + } + + val oversizedKnown = JSONArray() + repeat(2_000) { index -> + oversizedKnown.put( + JSONObject() + .put("address", "oversized-route-$index.example.test") + .put("port", 47989) + ) + } + val db = context.openOrCreateDatabase("computers4.db", 0, null) + val values = ContentValues().apply { + put("UUID", "uuid-oversized-addresses") + put("ComputerName", "Oversized Addresses") + put("Addresses", JSONObject().put("known", oversizedKnown).toString()) + } + db.insert("Computers", null, values) + db.close() + + val restoredManager = ComputerDatabaseManager(context) + try { + assertNull(restoredManager.getComputerByUUID("uuid-oversized-addresses")) + assertNull(restoredManager.getComputerByName("Oversized Addresses")) + assertEquals( + listOf("uuid-bounded-addresses"), + restoredManager.getAllComputers().map { it.uuid } + ) + } finally { + restoredManager.close() + } + } + + @Test + fun oversizedAddressStateCannotReplaceTheLastGoodRow() { + val manager = ComputerDatabaseManager(context) + try { + val lastGood = ComputerDetails().apply { + uuid = "uuid-oversized-write" + name = "Last Good" + rememberAddress(ComputerDetails.AddressTuple("valid.example.test", 47989)) + } + assertTrue(manager.updateComputer(lastGood)) + + val oversized = ComputerDetails(lastGood).apply { + name = "Must Not Replace" + manualAddress = ComputerDetails.AddressTuple("x".repeat(70_000), 47989) + } + assertFalse(manager.updateComputer(oversized)) + assertEquals("Last Good", manager.getComputerByUUID(lastGood.uuid)!!.name) + } finally { + manager.close() + } + } + + @Test + fun failedLegacyDestinationWritePreservesSourceDatabase() { + val legacyDb = context.openOrCreateDatabase("computers2.db", 0, null) + legacyDb.execSQL( + "CREATE TABLE Computers(UUID TEXT, ComputerName TEXT, LocalAddress TEXT, " + + "RemoteAddress TEXT, ManualAddress TEXT, MacAddress TEXT)" + ) + insertLegacy2Computer( + legacyDb, + "uuid-legacy-rolled-back", + "Legacy Rolled Back", + "local.example.test", + "remote.example.test", + "manual.example.test", + "11:22:33:44:55:65" + ) + insertLegacy2Computer( + legacyDb, + "uuid-legacy-write-failure", + "Legacy Write Failure", + "x".repeat(70_000), + "remote.example.test", + "manual.example.test", + "11:22:33:44:55:66" + ) + legacyDb.close() + + val manager = ComputerDatabaseManager(context) + try { + assertTrue( + "The legacy database must survive until every destination write succeeds", + context.getDatabasePath("computers2.db").exists() + ) + assertNull(manager.getComputerByUUID("uuid-legacy-rolled-back")) + assertNull(manager.getComputerByUUID("uuid-legacy-write-failure")) + } finally { + manager.close() + } + } + + @Test + fun unreadableLegacySchemasPreserveEverySourceDatabase() { + val legacyDatabaseNames = listOf("computers.db", "computers2.db", "computers3.db") + for (databaseName in legacyDatabaseNames) { + val legacyDb = context.openOrCreateDatabase(databaseName, 0, null) + legacyDb.execSQL("CREATE TABLE UnexpectedSchema(Value TEXT)") + legacyDb.close() + } + + val manager = ComputerDatabaseManager(context) + try { + for (databaseName in legacyDatabaseNames) { + assertTrue(context.getDatabasePath(databaseName).exists()) + } + assertEquals(emptyList(), manager.getAllComputers()) + } finally { + manager.close() + } + } + + @Test + fun successfulLegacyMigrationCommitsThenDeletesSourceDatabase() { + val legacyDb = context.openOrCreateDatabase("computers2.db", 0, null) + legacyDb.execSQL( + "CREATE TABLE Computers(UUID TEXT, ComputerName TEXT, LocalAddress TEXT, " + + "RemoteAddress TEXT, ManualAddress TEXT, MacAddress TEXT)" + ) + insertLegacy2Computer( + legacyDb, + "uuid-legacy-committed", + "Legacy Committed", + "local.example.test", + "remote.example.test", + "manual.example.test", + "11:22:33:44:55:67" + ) + legacyDb.close() + + val manager = ComputerDatabaseManager(context) + try { + assertFalse(context.getDatabasePath("computers2.db").exists()) + assertEquals("uuid-legacy-committed", manager.getComputerByUUID("uuid-legacy-committed")?.uuid) + } finally { + manager.close() + } + } + + @Test + fun legacyDatabaseReader1SkipsMalformedRowsAndMigratesValidSibling() { + val legacyDb = context.openOrCreateDatabase("computers.db", 0, null) + legacyDb.execSQL( + "CREATE TABLE Computers(ComputerName TEXT, UUID TEXT, LocalAddress BLOB, " + + "RemoteAddress BLOB, MacAddress TEXT)" + ) + fun insert(uuid: String, name: String, addressSuffix: Byte) { + val values = ContentValues().apply { + put("ComputerName", name) + put("UUID", uuid) + put("LocalAddress", byteArrayOf(10, 0, 0, addressSuffix)) + put("RemoteAddress", byteArrayOf(10, 0, 1, addressSuffix)) + put("MacAddress", "11:22:33:44:55:66") + } + legacyDb.insert("Computers", null, values) + } + insert("uuid-valid-legacy1", "Valid Legacy One", 2) + insert("", "Blank Legacy One", 3) + legacyDb.insert( + "Computers", + null, + ContentValues().apply { + put("ComputerName", "Malformed Legacy One") + put("UUID", "uuid-malformed-legacy1") + put("LocalAddress", "ADDRESS_PREFIX__ ".toByteArray()) + put("RemoteAddress", byteArrayOf(10, 0, 1, 4)) + put("MacAddress", "11:22:33:44:55:66") + } + ) + legacyDb.close() + + val manager = ComputerDatabaseManager(context) + try { + assertFalse(context.getDatabasePath("computers.db").exists()) + assertEquals("uuid-valid-legacy1", manager.getComputerByUUID("uuid-valid-legacy1")?.uuid) + assertEquals(listOf("uuid-valid-legacy1"), manager.getAllComputers().map { it.uuid }) + } finally { + manager.close() + } + } + + @Test + fun legacyDatabaseReader1SkipsBlankFallbackAddressRows() { + val cursor = org.mockito.Mockito.mock(android.database.Cursor::class.java) + org.mockito.Mockito.`when`(cursor.getString(1)).thenReturn("uuid-blank-fallback") + org.mockito.Mockito.`when`(cursor.getString(0)).thenReturn("Blank Fallback") + org.mockito.Mockito.`when`(cursor.getBlob(2)).thenReturn(byteArrayOf(1)) + org.mockito.Mockito.`when`(cursor.getString(2)).thenReturn("ADDRESS_PREFIX__ ") + + val method = LegacyDatabaseReader.Companion::class.java.getDeclaredMethod( + "getComputerFromCursor", + android.database.Cursor::class.java + ) + method.isAccessible = true + + assertNull(method.invoke(LegacyDatabaseReader.Companion, cursor)) + } + + @Test + fun getComputerByNameSkipsMalformedSiblingRows() { + ComputerDatabaseManager(context).close() + val db = context.openOrCreateDatabase("computers4.db", 0, null) + val malformed = ContentValues().apply { + put("UUID", "uuid-malformed-same-name") + put("ComputerName", "Shared Name") + put( + "Addresses", + JSONObject() + .put("local", JSONObject().put("address", "broken.example.test").put("port", 65536)) + .toString() + ) + } + db.insert("Computers", null, malformed) + db.close() + + val manager = ComputerDatabaseManager(context) + try { + val valid = ComputerDetails().apply { + uuid = "uuid-valid-same-name" + name = "Shared Name" + rememberAddress(ComputerDetails.AddressTuple("valid.example.test", 47989)) + } + assertTrue(manager.updateComputer(valid)) + + assertEquals("uuid-valid-same-name", manager.getComputerByName("Shared Name")?.uuid) + } finally { + manager.close() + } + } + + @Test + fun legacyDatabaseReader2SkipsMalformedCertificateRowsWithoutDeletingSource() { + val db = context.openOrCreateDatabase("computers2.db", 0, null) + db.execSQL( + "CREATE TABLE Computers(UUID TEXT, ComputerName TEXT, LocalAddress TEXT, " + + "RemoteAddress TEXT, ManualAddress TEXT, MacAddress TEXT, ServerCert BLOB)" + ) + fun insert(uuid: String, certificate: ByteArray?) { + val values = ContentValues().apply { + put("UUID", uuid) + put("ComputerName", uuid) + put("LocalAddress", "local.example.test") + put("RemoteAddress", "remote.example.test") + put("ManualAddress", "manual.example.test") + put("MacAddress", "11:22:33:44:55:66") + put("ServerCert", certificate) + } + db.insert("Computers", null, values) + } + insert("uuid-valid-legacy2", null) + insert("uuid-invalid-cert-legacy2", byteArrayOf(1, 2, 3, 4)) + db.close() + + val migrated = LegacyDatabaseReader2.migrateAllComputers(context) + + assertEquals(listOf("uuid-valid-legacy2"), migrated.map { it.uuid }) + assertTrue(context.getDatabasePath("computers2.db").exists()) + } + + @Test + fun legacyDatabaseReader3SkipsMalformedCertificateRowsWithoutDeletingSource() { + val db = context.openOrCreateDatabase("computers3.db", 0, null) + db.execSQL("CREATE TABLE Computers(UUID TEXT, ComputerName TEXT, Addresses TEXT, MacAddress TEXT, ServerCert BLOB)") + fun insert(uuid: String, certificate: ByteArray?) { + val values = ContentValues().apply { + put("UUID", uuid) + put("ComputerName", uuid) + put("Addresses", "local_47984;remote_48010;manual_48011;2001:db8::5_48012") + put("MacAddress", "22:33:44:55:66:77") + put("ServerCert", certificate) + } + db.insert("Computers", null, values) + } + insert("uuid-valid-legacy3", null) + insert("uuid-invalid-cert-legacy3", byteArrayOf(1, 2, 3, 4)) + db.close() + + val migrated = LegacyDatabaseReader3.migrateAllComputers(context) + + assertEquals(listOf("uuid-valid-legacy3"), migrated.map { it.uuid }) + assertTrue(context.getDatabasePath("computers3.db").exists()) + } + + @Test + fun legacyDatabaseReader2ReadsRowsSkipsNullUuidAndRetainsOldDatabase() { val db = context.openOrCreateDatabase("computers2.db", 0, null) db.execSQL("CREATE TABLE Computers(UUID TEXT, ComputerName TEXT, LocalAddress TEXT, RemoteAddress TEXT, ManualAddress TEXT, MacAddress TEXT)") insertLegacy2Computer(db, "uuid-2", "Legacy Two", "10.0.0.2", "remote-two", "manual-two", "11:22:33:44:55:66") insertLegacy2Computer(db, null, "Broken", "10.0.0.3", "remote-broken", "manual-broken", "00:00:00:00:00:00") + insertLegacy2Computer(db, "", "Blank UUID", "10.0.0.4", "remote-blank", "manual-blank", "00:00:00:00:00:01") + insertLegacy2Computer(db, "uuid-malformed-legacy2", "Malformed", "", "remote-malformed", "manual-malformed", "00:00:00:00:00:02") db.close() val migrated = LegacyDatabaseReader2.migrateAllComputers(context) @@ -154,11 +771,11 @@ class KotlinComputerPersistenceMigrationTest { assertEquals("remote-two", details.remoteAddress!!.address) assertEquals("manual-two", details.manualAddress!!.address) assertEquals("11:22:33:44:55:66", details.macAddress) - assertFalse(context.getDatabasePath("computers2.db").exists()) + assertTrue(context.getDatabasePath("computers2.db").exists()) } @Test - fun legacyDatabaseReader3MigratesDelimitedAddressesAndDeletesOldDatabase() { + fun legacyDatabaseReader3ReadsDelimitedAddressesAndRetainsOldDatabase() { val db = context.openOrCreateDatabase("computers3.db", 0, null) db.execSQL("CREATE TABLE Computers(UUID TEXT, ComputerName TEXT, Addresses TEXT, MacAddress TEXT, ServerCert BLOB)") val values = ContentValues() @@ -167,6 +784,20 @@ class KotlinComputerPersistenceMigrationTest { values.put("Addresses", "local_47984;remote_48010;manual_48011;2001:db8::5_48012") values.put("MacAddress", "22:33:44:55:66:77") db.insert("Computers", null, values) + val blankIdentity = ContentValues().apply { + put("UUID", "") + put("ComputerName", "Blank Identity") + put("Addresses", "local_47984;remote_48010;manual_48011;2001:db8::5_48012") + put("MacAddress", "00:00:00:00:00:01") + } + db.insert("Computers", null, blankIdentity) + val malformed = ContentValues().apply { + put("UUID", "uuid-malformed-legacy3") + put("ComputerName", "Malformed") + put("Addresses", "only-one-address") + put("MacAddress", "00:00:00:00:00:02") + } + db.insert("Computers", null, malformed) db.close() val migrated = LegacyDatabaseReader3.migrateAllComputers(context) @@ -184,7 +815,7 @@ class KotlinComputerPersistenceMigrationTest { assertEquals("2001:db8::5", details.ipv6Address!!.address) assertEquals(48012, details.ipv6Address!!.port) assertEquals("22:33:44:55:66:77", details.macAddress) - assertFalse(context.getDatabasePath("computers3.db").exists()) + assertTrue(context.getDatabasePath("computers3.db").exists()) } private fun insertLegacy2Computer( diff --git a/app/src/test/java/com/papi/nova/computers/KotlinComputerServiceMigrationTest.kt b/app/src/test/java/com/papi/nova/computers/KotlinComputerServiceMigrationTest.kt index fdb470b7e..53cf41567 100644 --- a/app/src/test/java/com/papi/nova/computers/KotlinComputerServiceMigrationTest.kt +++ b/app/src/test/java/com/papi/nova/computers/KotlinComputerServiceMigrationTest.kt @@ -5,9 +5,14 @@ import android.content.Intent import android.os.Binder import android.os.IBinder import com.papi.nova.nvstream.http.ComputerDetails +import com.papi.nova.nvstream.http.HostHttpResponseException import com.papi.nova.nvstream.http.NvHTTP +import javax.net.ssl.SSLHandshakeException import java.io.File +import java.security.cert.CertificateException +import java.security.cert.X509Certificate import java.util.concurrent.ScheduledFuture +import javax.net.ssl.X509TrustManager import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -123,6 +128,40 @@ class KotlinComputerServiceMigrationTest { assertEquals(true, matcher.invoke(service, "server-uuid", "server-uuid")) assertEquals(false, matcher.invoke(service, "other-uuid", "server-uuid")) assertEquals(false, matcher.invoke(service, "", null)) + assertEquals(false, matcher.invoke(service, "", "")) + assertEquals(false, matcher.invoke(service, "", " ")) + } + + @Test + fun pollCandidateWiringPassesStoredCertificateAndInvokesUuidGuard() { + // This is a narrow wiring guard. Strict pin and UUID behavior are exercised independently below. + val source = File("src/main/java/com/papi/nova/computers/ComputerManagerService.kt").readText() + val functionStart = source.indexOf("private fun tryPollIp") + val functionEnd = source.indexOf("\n private fun ", functionStart + 1) + assertTrue("tryPollIp should remain present", functionStart >= 0 && functionEnd > functionStart) + val body = source.substring(functionStart, functionEnd) + + val pinnedCertificate = body.indexOf("details.serverCert") + val fetchDetails = body.indexOf("http.getComputerDetails") + val uuidCheck = body.indexOf("else if (!isExpectedComputerUuid(expectedUuid, returnedUuid))") + val acceptedDetails = body.indexOf("\n newDetails", uuidCheck) + + assertTrue("polling must pass the stored server certificate before connecting", pinnedCertificate >= 0) + assertTrue("certificate pinning must be configured before host details are fetched", pinnedCertificate < fetchDetails) + assertTrue("poll results must be checked against the expected UUID", uuidCheck > fetchDetails) + assertTrue("host details must only be accepted after the UUID check", acceptedDetails > uuidCheck) + } + + @Test + fun pollComputerWiringUsesTheVerifiedRouteUpdateBoundary() { + val source = File("src/main/java/com/papi/nova/computers/ComputerManagerService.kt").readText() + val functionStart = source.indexOf("private fun pollComputer") + val functionEnd = source.indexOf("\n override fun ", functionStart + 1) + assertTrue("pollComputer should remain present", functionStart >= 0 && functionEnd > functionStart) + val body = source.substring(functionStart, functionEnd) + + assertTrue(body.contains("details.updateFromVerifiedPoll(polledDetails)")) + assertFalse(body.contains("details.update(polledDetails)")) } @Test @@ -153,6 +192,188 @@ class KotlinComputerServiceMigrationTest { ) } + @Test + fun parallelPollingIncludesRememberedRoutesWithoutDuplicates() { + val details = ComputerDetails() + details.localAddress = ComputerDetails.AddressTuple("100.100.20.30", NvHTTP.DEFAULT_HTTP_PORT) + details.manualAddress = ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", NvHTTP.DEFAULT_HTTP_PORT) + details.rememberAddress(details.localAddress) + details.rememberAddress(details.manualAddress) + details.rememberAddress(ComputerDetails.AddressTuple("192.168.1.25", NvHTTP.DEFAULT_HTTP_PORT)) + + val addresses = ComputerManagerService.buildParallelPollAddresses(details) + + assertEquals( + listOf( + ComputerDetails.AddressTuple("100.100.20.30", NvHTTP.DEFAULT_HTTP_PORT), + ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", NvHTTP.DEFAULT_HTTP_PORT), + ComputerDetails.AddressTuple("192.168.1.25", NvHTTP.DEFAULT_HTTP_PORT) + ), + addresses + ) + } + + @Test + fun parallelPollingPrefersRecentRememberedRoutesAndRetriesTheirDefaultPort() { + val details = ComputerDetails() + details.manualAddress = ComputerDetails.AddressTuple("current.example.test", 48000) + details.rememberAddress(ComputerDetails.AddressTuple("old.example.test", 49000)) + details.rememberAddress(ComputerDetails.AddressTuple("recent.example.test", 49100)) + + val addresses = ComputerManagerService.buildParallelPollAddresses(details) + + assertEquals( + listOf( + ComputerDetails.AddressTuple("current.example.test", 48000), + ComputerDetails.AddressTuple("recent.example.test", 49100), + ComputerDetails.AddressTuple("recent.example.test", NvHTTP.DEFAULT_HTTP_PORT), + ComputerDetails.AddressTuple("old.example.test", 49000), + ComputerDetails.AddressTuple("old.example.test", NvHTTP.DEFAULT_HTTP_PORT) + ), + addresses + ) + } + + @Test + fun serverInfoPlaintextFallbackAllowsOnlyRealHttpUnauthorizedResponses() { + assertTrue(NvHTTP.isServerInfoHttpFallbackAllowed(HostHttpResponseException(401, "Unauthorized"))) + assertFalse(NvHTTP.isServerInfoHttpFallbackAllowed(HostHttpResponseException(403, "Forbidden"))) + assertFalse(NvHTTP.isServerInfoHttpFallbackAllowed(SSLHandshakeException("Certificate mismatch"))) + } + + @Test + fun storedCertificateRejectsDifferentPlatformTrustedCertificate() { + val pinnedCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + val differentPlatformTrustedCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + var platformTrustChecks = 0 + val permissivePlatformTrust = object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkClientTrusted(chain: Array, authType: String) = Unit + override fun checkServerTrusted(chain: Array, authType: String) { + platformTrustChecks++ + } + } + + val trustManager = NvHTTP.createServerTrustManager(permissivePlatformTrust) { pinnedCertificate } + + org.junit.Assert.assertThrows(CertificateException::class.java) { + trustManager.checkServerTrusted(emptyArray(), "RSA") + } + org.junit.Assert.assertThrows(CertificateException::class.java) { + trustManager.checkServerTrusted(arrayOf(differentPlatformTrustedCertificate), "RSA") + } + assertEquals(0, platformTrustChecks) + + trustManager.checkServerTrusted( + arrayOf(pinnedCertificate, differentPlatformTrustedCertificate), + "RSA" + ) + assertEquals(0, platformTrustChecks) + } + + @Test + fun pinnedLeafBypassesHostnameVerificationForMultiCertificateChains() { + val clientCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + val clientPrivateKey = org.mockito.Mockito.mock(java.security.PrivateKey::class.java) + val pinnedServerCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + val intermediateCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + val cryptoProvider = org.mockito.Mockito.mock(com.papi.nova.nvstream.http.LimelightCryptoProvider::class.java) + org.mockito.Mockito.`when`(cryptoProvider.clientCertificate).thenReturn(clientCertificate) + org.mockito.Mockito.`when`(cryptoProvider.clientPrivateKey).thenReturn(clientPrivateKey) + org.mockito.Mockito.`when`(cryptoProvider.pemEncodedClientCertificate).thenReturn(byteArrayOf(1)) + + val http = NvHTTP( + ComputerDetails.AddressTuple("127.0.0.1", NvHTTP.DEFAULT_HTTP_PORT), + 0, + "test-client", + pinnedServerCertificate, + cryptoProvider + ) + val clientField = NvHTTP::class.java.getDeclaredField("httpClientLongConnectTimeout") + clientField.isAccessible = true + val client = clientField.get(http) as okhttp3.OkHttpClient + val session = org.mockito.Mockito.mock(javax.net.ssl.SSLSession::class.java) + org.mockito.Mockito.`when`(session.peerCertificates).thenReturn( + arrayOf(pinnedServerCertificate, intermediateCertificate) + ) + + assertTrue(client.hostnameVerifier.verify("127.0.0.1", session)) + } + + @Test + fun nvHttpTrustManagerTracksStoredCertificateChanges() { + val clientCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + val clientPrivateKey = org.mockito.Mockito.mock(java.security.PrivateKey::class.java) + val firstServerCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + val secondServerCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + val cryptoProvider = org.mockito.Mockito.mock(com.papi.nova.nvstream.http.LimelightCryptoProvider::class.java) + org.mockito.Mockito.`when`(cryptoProvider.clientCertificate).thenReturn(clientCertificate) + org.mockito.Mockito.`when`(cryptoProvider.clientPrivateKey).thenReturn(clientPrivateKey) + org.mockito.Mockito.`when`(cryptoProvider.pemEncodedClientCertificate).thenReturn(byteArrayOf(1)) + + val http = NvHTTP( + ComputerDetails.AddressTuple("127.0.0.1", NvHTTP.DEFAULT_HTTP_PORT), + 0, + "test-client", + firstServerCertificate, + cryptoProvider + ) + val field = NvHTTP::class.java.getDeclaredField("trustManager") + field.isAccessible = true + val trustManager = field.get(http) as X509TrustManager + + trustManager.checkServerTrusted(arrayOf(firstServerCertificate), "RSA") + http.setServerCert(secondServerCertificate) + org.junit.Assert.assertThrows(CertificateException::class.java) { + trustManager.checkServerTrusted(arrayOf(firstServerCertificate), "RSA") + } + trustManager.checkServerTrusted(arrayOf(secondServerCertificate), "RSA") + } + + @Test + fun storedCertificateProviderIsReevaluatedAfterPairing() { + val firstCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + val pairedCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + var storedCertificate: X509Certificate? = null + var platformTrustChecks = 0 + val platformTrust = object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkClientTrusted(chain: Array, authType: String) = Unit + override fun checkServerTrusted(chain: Array, authType: String) { + platformTrustChecks++ + } + } + val trustManager = NvHTTP.createServerTrustManager(platformTrust) { storedCertificate } + + trustManager.checkServerTrusted(arrayOf(firstCertificate), "RSA") + assertEquals(1, platformTrustChecks) + + storedCertificate = pairedCertificate + org.junit.Assert.assertThrows(CertificateException::class.java) { + trustManager.checkServerTrusted(arrayOf(firstCertificate), "RSA") + } + trustManager.checkServerTrusted(arrayOf(pairedCertificate), "RSA") + assertEquals(1, platformTrustChecks) + } + + @Test + fun unpinnedConnectionDelegatesToPlatformTrustManager() { + val platformTrustedCertificate = org.mockito.Mockito.mock(X509Certificate::class.java) + var platformTrustChecks = 0 + val platformTrust = object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkClientTrusted(chain: Array, authType: String) = Unit + override fun checkServerTrusted(chain: Array, authType: String) { + platformTrustChecks++ + } + } + + val trustManager = NvHTTP.createServerTrustManager(platformTrust) { null } + trustManager.checkServerTrusted(arrayOf(platformTrustedCertificate), "RSA") + + assertEquals(1, platformTrustChecks) + } + @Test fun pollingTupleAndReachabilityTupleKeepJavaFieldShape() { val computer = ComputerDetails() diff --git a/app/src/test/java/com/papi/nova/nvstream/http/ComputerDetailsKnownAddressesTest.kt b/app/src/test/java/com/papi/nova/nvstream/http/ComputerDetailsKnownAddressesTest.kt new file mode 100644 index 000000000..b21ba5b3c --- /dev/null +++ b/app/src/test/java/com/papi/nova/nvstream/http/ComputerDetailsKnownAddressesTest.kt @@ -0,0 +1,215 @@ +package com.papi.nova.nvstream.http + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class ComputerDetailsKnownAddressesTest { + @Test + fun addressTupleRejectsAnEmptyCanonicalAddress() { + assertThrows(IllegalArgumentException::class.java) { + ComputerDetails.AddressTuple(" . ", 47989) + } + } + + @Test + fun addressTupleRejectsPortsOutsideTheTcpRange() { + assertThrows(IllegalArgumentException::class.java) { + ComputerDetails.AddressTuple("pc.example.test", 0) + } + assertThrows(IllegalArgumentException::class.java) { + ComputerDetails.AddressTuple("pc.example.test", 65_536) + } + } + + @Test + fun updateRejectsDifferentComputerUuidBeforeMutatingState() { + val existing = ComputerDetails().apply { + uuid = "trusted-host" + name = "Trusted Host" + rememberAddress(ComputerDetails.AddressTuple("192.168.1.25", 47989)) + } + val other = ComputerDetails().apply { + uuid = "different-host" + name = "Different Host" + rememberAddress(ComputerDetails.AddressTuple("attacker.example.test", 47989)) + } + + assertThrows(IllegalArgumentException::class.java) { + existing.update(other) + } + + assertEquals("trusted-host", existing.uuid) + assertEquals("Trusted Host", existing.name) + assertEquals( + listOf(ComputerDetails.AddressTuple("192.168.1.25", 47989)), + existing.knownAddresses + ) + } + + @Test + fun genericUpdateDoesNotPromoteAdvertisedRoutesIntoVerifiedHistory() { + val existing = ComputerDetails().apply { + uuid = "host-uuid" + rememberAddress(ComputerDetails.AddressTuple("verified.example.test", 47989)) + } + val advertised = ComputerDetails().apply { + uuid = "host-uuid" + localAddress = ComputerDetails.AddressTuple("192.168.1.99", 47989) + remoteAddress = ComputerDetails.AddressTuple("wan.example.test", 47989) + manualAddress = ComputerDetails.AddressTuple("manual.example.test", 47989) + ipv6Address = ComputerDetails.AddressTuple("2001:db8::99", 47989) + activeAddress = ComputerDetails.AddressTuple("active.example.test", 47989) + } + + existing.update(advertised) + + assertEquals( + listOf(ComputerDetails.AddressTuple("verified.example.test", 47989)), + existing.knownAddresses + ) + } + + @Test + fun updateRetainsPreviousAndIncomingRememberedRoutesForTheSameComputer() { + val existing = ComputerDetails().apply { + uuid = "host-uuid" + localAddress = ComputerDetails.AddressTuple("192.168.1.25", 47989) + rememberAddress(localAddress) + } + val viaTailnet = ComputerDetails().apply { + uuid = "host-uuid" + localAddress = ComputerDetails.AddressTuple("100.100.20.30", 47989) + activeAddress = ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989) + rememberAddress(activeAddress) + } + + existing.update(viaTailnet) + + assertEquals(ComputerDetails.AddressTuple("100.100.20.30", 47989), existing.localAddress) + assertEquals( + listOf( + ComputerDetails.AddressTuple("192.168.1.25", 47989), + ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989) + ), + existing.knownAddresses + ) + } + + @Test + fun verifiedPollPromotesOnlyTheSuccessfulActiveRoute() { + val existing = ComputerDetails().apply { + uuid = "host-uuid" + rememberAddress(ComputerDetails.AddressTuple("previous.example.test", 47989)) + } + val polled = ComputerDetails().apply { + uuid = "host-uuid" + localAddress = ComputerDetails.AddressTuple("advertised-lan.example.test", 47989) + remoteAddress = ComputerDetails.AddressTuple("advertised-wan.example.test", 47989) + activeAddress = ComputerDetails.AddressTuple("successful.example.test", 47989) + } + + existing.updateFromVerifiedPoll(polled) + + assertEquals(ComputerDetails.AddressTuple("advertised-lan.example.test", 47989), existing.localAddress) + assertEquals( + listOf( + ComputerDetails.AddressTuple("previous.example.test", 47989), + ComputerDetails.AddressTuple("successful.example.test", 47989) + ), + existing.knownAddresses + ) + } + + @Test + fun verifiedPollRequiresANonBlankComputerUuidBeforeMutatingState() { + val existing = ComputerDetails().apply { + name = "Before" + } + val invalidPoll = ComputerDetails().apply { + name = "After" + activeAddress = ComputerDetails.AddressTuple("active.example.test", 47989) + } + + assertThrows(IllegalArgumentException::class.java) { + existing.updateFromVerifiedPoll(invalidPoll) + } + + assertEquals("Before", existing.name) + assertEquals(emptyList(), existing.knownAddresses) + } + + @Test + fun verifiedPollRequiresAnActiveRouteBeforeMutatingState() { + val existing = ComputerDetails().apply { + uuid = "host-uuid" + name = "Before" + } + val invalidPoll = ComputerDetails().apply { + uuid = "host-uuid" + name = "After" + } + + assertThrows(IllegalArgumentException::class.java) { + existing.updateFromVerifiedPoll(invalidPoll) + } + + assertEquals("Before", existing.name) + assertEquals(emptyList(), existing.knownAddresses) + } + + @Test + fun rememberAddressDeduplicatesEquivalentDnsNames() { + val details = ComputerDetails() + + details.rememberAddress(ComputerDetails.AddressTuple(" PC-PAPI.TAILNET.TS.NET. ", 47989)) + details.rememberAddress(ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989)) + + assertEquals(1, details.knownAddresses.size) + assertEquals("pc-papi.tailnet.ts.net", details.knownAddresses.single().address) + } + + @Test + fun rememberAddressKeepsDifferentPortsAsDifferentEndpoints() { + val details = ComputerDetails() + + details.rememberAddress(ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989)) + details.rememberAddress(ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 48000)) + + assertEquals(2, details.knownAddresses.size) + } + + @Test + fun knownAddressesReturnsADeepDefensiveSnapshot() { + val details = ComputerDetails() + details.rememberAddress(ComputerDetails.AddressTuple("192.168.1.25", 47989)) + val snapshot = details.knownAddresses + + snapshot.single().address = "mutated.example.test" + snapshot.single().port = 1 + details.rememberAddress(ComputerDetails.AddressTuple("pc-papi.tailnet.ts.net", 47989)) + + assertEquals("mutated.example.test", snapshot.single().address) + assertEquals(1, snapshot.single().port) + assertEquals( + ComputerDetails.AddressTuple("192.168.1.25", 47989), + details.knownAddresses.first() + ) + assertEquals(2, details.knownAddresses.size) + } + + @Test + fun rememberAddressBoundsHistoryAndKeepsMostRecentEndpoints() { + val details = ComputerDetails() + repeat(ComputerDetails.MAX_KNOWN_ADDRESSES + 2) { index -> + details.rememberAddress(ComputerDetails.AddressTuple("100.64.0.${index + 1}", 47989)) + } + + assertEquals(ComputerDetails.MAX_KNOWN_ADDRESSES, details.knownAddresses.size) + assertEquals(ComputerDetails.AddressTuple("100.64.0.3", 47989), details.knownAddresses.first()) + assertEquals( + ComputerDetails.AddressTuple("100.64.0.${ComputerDetails.MAX_KNOWN_ADDRESSES + 2}", 47989), + details.knownAddresses.last() + ) + } +}