Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
533 changes: 533 additions & 0 deletions app/schemas/dev.threadline.data.db.ThreadlineDatabase/6.json

Large diffs are not rendered by default.

179 changes: 176 additions & 3 deletions app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import dev.threadline.core.transcript.CommandOutput
import dev.threadline.core.transcript.CommandStatus
import dev.threadline.core.transcript.CommandTurn
import dev.threadline.data.host.KnownHostMetadata
import dev.threadline.data.identity.IdentityAuthenticationMethod
import dev.threadline.data.identity.SshIdentity
import dev.threadline.data.key.ImportedPrivateKeyMetadata
import dev.threadline.data.profile.SavedHostProfile
import dev.threadline.data.transcript.SavedTranscriptSession
Expand Down Expand Up @@ -68,7 +70,7 @@ class ConnectionFormRetentionTest {
}

compose.onNodeWithText("Connect to a server").assertExists()
compose.onNodeWithText("Passwords and private-key passphrases are never saved", substring = true)
compose.onNodeWithText("Passwords and private-key passphrases are entered", substring = true)
.performScrollTo()
.assertExists()
compose.onNodeWithTag(ConnectionFormTags.HELP).performClick()
Expand Down Expand Up @@ -744,12 +746,31 @@ class ConnectionFormRetentionTest {
hostProfiles = profiles.value,
selectedHostProfileId = selectedId.value,
onSelectedHostProfileChange = { selectedId.value = it },
onSaveHostProfile = { profile ->
onCreateDefaultSshIdentity = { profile, method, keyId ->
SshIdentity(
id = "default-identity",
label = profile.displayName,
username = profile.username,
authenticationMethod = when (method) {
AuthenticationMode.PASSWORD ->
IdentityAuthenticationMethod.PASSWORD
AuthenticationMode.PRIVATE_KEY -> if (keyId == null) {
IdentityAuthenticationMethod.UNCONFIGURED
} else {
IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY
}
},
importedPrivateKeyId = keyId,
createdAtMillis = 1,
updatedAtMillis = 1,
)
},
onSaveHostProfile = { profile, _ ->
savedProfile = profile
profiles.value = listOf(original)
original
},
onUpdateHostProfile = { id, profile ->
onUpdateHostProfile = { id, profile, _ ->
updatedProfile = id to profile
},
onDeleteHostProfile = { id ->
Expand Down Expand Up @@ -819,6 +840,157 @@ class ConnectionFormRetentionTest {
.assertEditableTextEquals("")
}

@Test
fun preferredIdentitySelectionSetsUsernameAndSavesOnlyTheProfilePreference() {
val originalIdentity = SshIdentity(
id = "legacy-identity",
label = "Legacy login",
username = "operator",
authenticationMethod = IdentityAuthenticationMethod.UNCONFIGURED,
importedPrivateKeyId = null,
createdAtMillis = 1,
updatedAtMillis = 1,
)
val selectedIdentity = SshIdentity(
id = "deploy-identity",
label = "Deploy account",
username = "deploy",
authenticationMethod = IdentityAuthenticationMethod.PASSWORD,
importedPrivateKeyId = null,
createdAtMillis = 2,
updatedAtMillis = 2,
)
val profile = SavedHostProfile(
id = "profile-id",
displayName = "Lab",
hostname = "lab.example",
port = 22,
username = originalIdentity.username,
createdAtMillis = 1,
updatedAtMillis = 1,
preferredIdentityId = originalIdentity.id,
)
val selectedId = mutableStateOf<String?>(null)
val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults())
var updatedPreference: String? = null

compose.setContent {
MaterialTheme {
HostForm(
draft = draft.value,
onDraftChange = { draft.value = it },
sessionError = null,
initialTask = HomeTask.OVERVIEW,
hostProfiles = listOf(profile),
selectedHostProfileId = selectedId.value,
onSelectedHostProfileChange = { selectedId.value = it },
sshIdentities = listOf(originalIdentity, selectedIdentity),
onUpdateHostProfile = { _, _, identityId ->
updatedPreference = identityId
},
onPrepared = { true },
)
}
}

compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id)
.performClick()
compose.onNodeWithTag(ConnectionFormTags.USERNAME)
.assertEditableTextEquals("operator")
compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY).performClick()
compose.onNodeWithText("Deploy account").performClick()
compose.onNodeWithTag(ConnectionFormTags.USERNAME)
.assertEditableTextEquals("deploy")
compose.onNodeWithTag(ConnectionFormTags.UPDATE_PROFILE)
.performScrollTo()
.performClick()
compose.waitForIdle()

assertEquals(selectedIdentity.id, updatedPreference)
}

@Test
fun savedProfileWithoutIdentityMustBeRepairedBeforeConnecting() {
val profile = SavedHostProfile(
id = "unlinked-profile",
displayName = "Lab",
hostname = "lab.example",
port = 22,
username = "operator",
createdAtMillis = 1,
updatedAtMillis = 1,
)
var preparedCount = 0

compose.setContent {
MaterialTheme {
HostForm(
draft = ConnectionFormDraft.fixtureDefaults(),
onDraftChange = {},
sessionError = null,
initialTask = HomeTask.CONNECTION,
hostProfiles = listOf(profile),
selectedHostProfileId = profile.id,
notificationPermissionState = SessionNotificationPermissionState.GRANTED,
onPrepared = {
preparedCount += 1
it.credential.clear()
true
},
)
}
}

compose.onNodeWithTag(ConnectionFormTags.CONNECT)
.performScrollTo()
.performClick()
compose.onNodeWithText("Choose an SSH identity for this saved profile", substring = true)
.performScrollTo()
.assertIsDisplayed()
compose.runOnIdle { assertEquals(0, preparedCount) }
}

@Test
fun deletingIdentityRequiresConfirmation() {
val identity = SshIdentity(
id = "identity-id",
label = "Work",
username = "operator",
authenticationMethod = IdentityAuthenticationMethod.PASSWORD,
importedPrivateKeyId = null,
createdAtMillis = 1,
updatedAtMillis = 1,
)
var deletedId: String? = null

compose.setContent {
MaterialTheme {
HostForm(
draft = ConnectionFormDraft.emptyDefaults(),
onDraftChange = {},
sessionError = null,
initialTask = HomeTask.OVERVIEW,
sshIdentities = listOf(identity),
onDeleteSshIdentity = { deletedId = it },
onPrepared = { true },
)
}
}

compose.onNodeWithTag(ConnectionFormTags.OPEN_SECURITY)
.performScrollTo()
.performClick()
compose.onNodeWithTag(IdentityTags.DELETE_PREFIX + identity.id)
.performScrollTo()
.performClick()
assertNull(deletedId)
compose.onNodeWithText("Delete SSH identity?").assertExists()
compose.onNodeWithTag(IdentityTags.CONFIRM_DELETE).performClick()
compose.waitForIdle()

assertEquals(identity.id, deletedId)
}

@Test
fun changedHostTrustCanOnlyBeForgottenAfterExplicitConfirmation() {
val trustedHost = KnownHostMetadata(
Expand Down Expand Up @@ -985,6 +1157,7 @@ class ConnectionFormRetentionTest {
compose.onNodeWithTag(ConnectionFormTags.CONNECT).assertDoesNotExist()

compose.onNodeWithTag(ConnectionFormTags.REQUEST_NOTIFICATION_PERMISSION)
.performScrollTo()
.performClick()
compose.runOnIdle {
assertEquals(1, requestCount)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,119 @@ class ThreadlineDatabaseMigrationTest {
migrated.close()
}

@Test
@Throws(IOException::class)
fun migrationFromFiveCreatesSeparateUnconfiguredIdentityForEachProfile() {
helper.createDatabase(DATABASE_NAME, 5).apply {
execSQL(
"""
INSERT INTO known_hosts (
endpoint_key, hostname, port, algorithm, encoded_key,
first_seen_at_millis, last_seen_at_millis
) VALUES ('fixture.test:22', 'fixture.test', 22, 'ssh-ed25519', X'010203', 10, 20)
""".trimIndent(),
)
execSQL(
"""
INSERT INTO imported_private_keys (
id, display_name, format, key_type, public_key_fingerprint,
ciphertext, initialization_vector, created_at_millis, crypto_version
) VALUES (
'key-id', 'Fixture key', 'OpenSSH', 'ssh-ed25519', 'fixture-fingerprint',
X'010203', X'040506', 30, 1
)
""".trimIndent(),
)
execSQL(
"""
INSERT INTO host_profiles (
id, display_name, hostname, port, username,
created_at_millis, updated_at_millis
) VALUES
('profile-id', 'Fixture', 'fixture.test', 2222, 'threadline', 40, 50),
('profile-id-2', 'Fixture 2', 'fixture2.test', 22, 'threadline', 60, 70)
""".trimIndent(),
)
execSQL(
"""
INSERT INTO transcript_sessions (
session_id, display_name, hostname, port, username,
started_at_millis, ended_at_millis, turns_truncated
) VALUES ('session-id', 'Fixture', 'fixture.test', 22, 'threadline', 10, 20, 0)
""".trimIndent(),
)
close()
}

val migrated = helper.runMigrationsAndValidate(
DATABASE_NAME,
6,
true,
ThreadlineDatabase.MIGRATION_5_6,
)

migrated.query(
"""
SELECT id, label, username, authentication_method, imported_private_key_id,
created_at_millis, updated_at_millis
FROM ssh_identities
WHERE id = 'legacy-profile-id'
""".trimIndent(),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("legacy-profile-id", cursor.getString(0))
assertEquals("Fixture", cursor.getString(1))
assertEquals("threadline", cursor.getString(2))
assertEquals("UNCONFIGURED", cursor.getString(3))
assertTrue(cursor.isNull(4))
assertEquals(40L, cursor.getLong(5))
assertEquals(50L, cursor.getLong(6))
}
migrated.query(
"SELECT preferred_identity_id FROM host_profiles WHERE id = 'profile-id'",
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("legacy-profile-id", cursor.getString(0))
}
migrated.query(
"""
SELECT id, username, authentication_method, imported_private_key_id
FROM ssh_identities WHERE id = 'legacy-profile-id-2'
""".trimIndent(),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("legacy-profile-id-2", cursor.getString(0))
assertEquals("threadline", cursor.getString(1))
assertEquals("UNCONFIGURED", cursor.getString(2))
assertTrue(cursor.isNull(3))
}
migrated.query(
"SELECT preferred_identity_id FROM host_profiles WHERE id = 'profile-id-2'",
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("legacy-profile-id-2", cursor.getString(0))
}
listOf(
"known_hosts",
"imported_private_keys",
"transcript_sessions",
).forEach { table ->
migrated.query("SELECT COUNT(*) FROM `$table`").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
}
}
migrated.query("SELECT COUNT(*) FROM host_profiles").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(2, cursor.getInt(0))
}
migrated.query("SELECT COUNT(*) FROM ssh_identities").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(2, cursor.getInt(0))
}
migrated.close()
}

private companion object {
const val DATABASE_NAME = "threadline-migration-test"
}
Expand Down
Loading
Loading