* fix(#478): rework default login to use unified ConnectionProfile model - AuthManager: drop legacy auth_token path; getToken/setToken now delegate to the selected profile (profile token only, no global fallback) - setHost/setPort always write the selected profile (no top-level host/port branch) - Add DEFAULT_PROFILE_ID + ensureDefaultProfile()/ensureDefaultSelected() so the selected profile is never null; ensureDefaultSelected() only falls back when nothing is selected (never clobbers an existing choice); ensureDefaultProfile() only injects a Default profile on fresh install, never into an existing list - One-time migration folds legacy standalone auth_token into the Default profile, batched into a single SharedPreferences transaction (guarded by legacy_default_migrated) - ConnectViewModel.connect() persists via setToken/setHost/setPort (delegate to the selected profile) — keeps the existing persistence contract - Connect/Settings: remove the '(none)'/standalone profile option; deleteProfile falls back to Default profile instead of null selection - Update AuthManagerTest/ConnectViewModelTest to the unified model; fix test stub ordering + migration re-init; remove dead connect_profile_none / settings_profile_none strings Closes #478 * fix: stub AuthManager.ensureDefaultSelected() in E2eIntegrationTest The #478 ConnectViewModel.connect() success branch now calls AuthManager.ensureDefaultSelected() on the non-saveProfile path. E2eIntegrationTest.setUp() mocks AuthManager as a non-relaxed mockkObject but never stubs that method, so the unstubbed call throws MockKException inside viewModelScope.launch, leaving connectionSuccess unset and failing 3 tests: - testFullUserSessionFlow - testConnectViewModel_onPairingString_base64Format - testConnectViewModel_onPairingString_urlFormat Add the missing stub. Verified locally: full suite 322/322 passing. --------- Co-authored-by: ronia <ronia@m57>
This commit is contained in:
1 parent
b65d0297e0
commit
aa59e704a4
11 files changed
+229
-155
No files matched your search
@@ -8,6 +8,9 @@ class HermesControlApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AuthManager.init(this)
|
||||
// Issue #478: guarantee a "Default" profile is always selected so there is no
|
||||
// separate standalone/default code path anywhere in the app.
|
||||
AuthManager.ensureDefaultProfile()
|
||||
NetworkMonitor.init(this)
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,12 @@ import kotlinx.coroutines.runBlocking
|
||||
object AuthManager {
|
||||
private const val PREFS_FILE = "hermes_secure_prefs"
|
||||
|
||||
private const val KEY_TOKEN = "auth_token"
|
||||
const val DEFAULT_PROFILE_ID = "default"
|
||||
const val DEFAULT_PROFILE_NAME = "Default"
|
||||
private const val KEY_SELECTED_PROFILE_ID = "selected_profile_id"
|
||||
private const val KEY_SESSION_COOKIE = "session_cookie"
|
||||
private const val KEY_LEGACY_TOKEN = "auth_token"
|
||||
private const val KEY_LEGACY_DEFAULT_MIGRATED = "legacy_default_migrated"
|
||||
|
||||
@Volatile
|
||||
private var prefsDeferred: Deferred<SharedPreferences>? = null
|
||||
@@ -114,6 +118,7 @@ object AuthManager {
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
migrateLegacyDefaultIfNeeded(p)
|
||||
_tokenFlow.value = getTokenInternal(p)
|
||||
p
|
||||
}
|
||||
@@ -182,6 +187,75 @@ object AuthManager {
|
||||
serverStore.update { it.copy(connectionProfiles = profiles) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantees the selected profile is never null (issue #478).
|
||||
*
|
||||
* - If a profile is already selected (default or otherwise), nothing is changed.
|
||||
* - If nothing is selected but other profiles exist, the first one is selected.
|
||||
* - If there are no profiles at all (fresh install / legacy standalone), a [DEFAULT_PROFILE_ID]
|
||||
* profile is created from the current top-level host/port and selected.
|
||||
*
|
||||
* This never injects a Default profile into an existing user's profile list, and never
|
||||
* clobbers a user's explicit selection.
|
||||
*/
|
||||
fun ensureDefaultProfile() {
|
||||
val state = serverStore.getLatestState()
|
||||
val hasDefault = state.connectionProfiles.any { it.id == DEFAULT_PROFILE_ID }
|
||||
val needsSelection = state.selectedProfileId.isNullOrBlank()
|
||||
|
||||
if (!needsSelection && (hasDefault || state.connectionProfiles.isNotEmpty())) return
|
||||
|
||||
if (state.connectionProfiles.isEmpty()) {
|
||||
// Fresh install / legacy standalone: create the Default profile and select it.
|
||||
serverStore.update { s ->
|
||||
s.copy(
|
||||
connectionProfiles =
|
||||
listOf(
|
||||
ConnectionProfile(
|
||||
id = DEFAULT_PROFILE_ID,
|
||||
name = DEFAULT_PROFILE_NAME,
|
||||
host = s.host,
|
||||
port = s.port,
|
||||
),
|
||||
),
|
||||
selectedProfileId = DEFAULT_PROFILE_ID,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Profiles exist but nothing is selected: pick the first one so selection is non-null.
|
||||
if (needsSelection) {
|
||||
serverStore.update { s -> s.copy(selectedProfileId = s.connectionProfiles.first().id) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure a profile is selected (the default one if nothing else), so callers never see null. */
|
||||
fun ensureDefaultSelected() {
|
||||
ensureDefaultProfile()
|
||||
if (getSelectedProfileId().isNullOrBlank()) {
|
||||
setSelectedProfileId(DEFAULT_PROFILE_ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration: fold the legacy standalone ([KEY_LEGACY_TOKEN]) credentials into the
|
||||
* new default [ConnectionProfile]. Runs once per install, guarded by
|
||||
* [KEY_LEGACY_DEFAULT_MIGRATED].
|
||||
*/
|
||||
private fun migrateLegacyDefaultIfNeeded(p: SharedPreferences) {
|
||||
if (p.getBoolean(KEY_LEGACY_DEFAULT_MIGRATED, false)) return
|
||||
val legacyToken = p.getString(KEY_LEGACY_TOKEN, null)
|
||||
ensureDefaultProfile()
|
||||
p.edit().apply {
|
||||
if (!legacyToken.isNullOrBlank()) {
|
||||
putString("token_$DEFAULT_PROFILE_ID", legacyToken)
|
||||
}
|
||||
remove(KEY_LEGACY_TOKEN)
|
||||
putBoolean(KEY_LEGACY_DEFAULT_MIGRATED, true)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
// ── Pinned Models ────────────────────────────────────────────────────
|
||||
|
||||
fun getPinnedModels(): List<PinnedModel> = serverStore.getLatestState().pinnedModels
|
||||
@@ -253,12 +327,7 @@ object AuthManager {
|
||||
synchronized(this) {
|
||||
if (tokenInitialized) return cachedToken
|
||||
val selectedId = getSelectedProfileId()
|
||||
val token =
|
||||
if (selectedId != null) {
|
||||
getProfileToken(selectedId) ?: requirePrefs().getString(KEY_TOKEN, null)
|
||||
} else {
|
||||
requirePrefs().getString(KEY_TOKEN, null)
|
||||
}
|
||||
val token = if (selectedId != null) getProfileToken(selectedId) else null
|
||||
cachedToken = token
|
||||
tokenInitialized = true
|
||||
return token
|
||||
@@ -268,20 +337,19 @@ object AuthManager {
|
||||
private fun getTokenInternal(p: SharedPreferences): String? {
|
||||
val selectedId = serverStore.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() }
|
||||
return if (selectedId != null) {
|
||||
p.getString("token_$selectedId", null) ?: p.getString(KEY_TOKEN, null)
|
||||
p.getString("token_$selectedId", null)
|
||||
} else {
|
||||
p.getString(KEY_TOKEN, null)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun setToken(token: String?) {
|
||||
val selectedId = getSelectedProfileId()
|
||||
if (selectedId != null) {
|
||||
setProfileToken(selectedId, token)
|
||||
} else {
|
||||
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
|
||||
_tokenFlow.value = token
|
||||
}
|
||||
val selectedId =
|
||||
getSelectedProfileId() ?: run {
|
||||
ensureDefaultSelected()
|
||||
DEFAULT_PROFILE_ID
|
||||
}
|
||||
setProfileToken(selectedId, token)
|
||||
synchronized(this) {
|
||||
cachedToken = token
|
||||
tokenInitialized = true
|
||||
@@ -293,17 +361,17 @@ object AuthManager {
|
||||
fun getHost(): String = serverStore.getLatestState().resolvedHost
|
||||
|
||||
fun setHost(host: String) {
|
||||
val selectedId = getSelectedProfileId()
|
||||
if (selectedId != null) {
|
||||
serverStore.update { state ->
|
||||
val profiles =
|
||||
state.connectionProfiles.map {
|
||||
if (it.id == selectedId) it.copy(host = host) else it
|
||||
}
|
||||
state.copy(connectionProfiles = profiles)
|
||||
val selectedId =
|
||||
getSelectedProfileId() ?: run {
|
||||
ensureDefaultSelected()
|
||||
DEFAULT_PROFILE_ID
|
||||
}
|
||||
} else {
|
||||
serverStore.update { it.copy(host = host) }
|
||||
serverStore.update { state ->
|
||||
val profiles =
|
||||
state.connectionProfiles.map {
|
||||
if (it.id == selectedId) it.copy(host = host) else it
|
||||
}
|
||||
state.copy(connectionProfiles = profiles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,17 +380,17 @@ object AuthManager {
|
||||
fun getPort(): Int = serverStore.getLatestState().resolvedPort
|
||||
|
||||
fun setPort(port: Int) {
|
||||
val selectedId = getSelectedProfileId()
|
||||
if (selectedId != null) {
|
||||
serverStore.update { state ->
|
||||
val profiles =
|
||||
state.connectionProfiles.map {
|
||||
if (it.id == selectedId) it.copy(port = port) else it
|
||||
}
|
||||
state.copy(connectionProfiles = profiles)
|
||||
val selectedId =
|
||||
getSelectedProfileId() ?: run {
|
||||
ensureDefaultSelected()
|
||||
DEFAULT_PROFILE_ID
|
||||
}
|
||||
} else {
|
||||
serverStore.update { it.copy(port = port) }
|
||||
serverStore.update { state ->
|
||||
val profiles =
|
||||
state.connectionProfiles.map {
|
||||
if (it.id == selectedId) it.copy(port = port) else it
|
||||
}
|
||||
state.copy(connectionProfiles = profiles)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,13 +154,6 @@ fun ConnectScreen(
|
||||
onDismissRequest = { profilesExpanded = false },
|
||||
modifier = Modifier.fillMaxWidth(0.85f),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.connect_profile_none)) },
|
||||
onClick = {
|
||||
viewModel.selectProfile(null)
|
||||
profilesExpanded = false
|
||||
},
|
||||
)
|
||||
state.profiles.forEach { profile ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(profile.name) },
|
||||
|
||||
@@ -54,12 +54,7 @@ class ConnectViewModel(
|
||||
val selectedProfile = profiles.firstOrNull { it.id == selectedId }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
token =
|
||||
if (selectedProfile != null) {
|
||||
AuthManager.getProfileToken(selectedProfile.id) ?: ""
|
||||
} else {
|
||||
savedToken
|
||||
},
|
||||
token = savedToken,
|
||||
host = selectedProfile?.host ?: savedHost,
|
||||
port = (selectedProfile?.port ?: savedPort).toString(),
|
||||
profiles = profiles,
|
||||
@@ -77,30 +72,17 @@ class ConnectViewModel(
|
||||
_uiState.update { it.copy(saveProfile = value) }
|
||||
}
|
||||
|
||||
fun selectProfile(profile: ConnectionProfile?) {
|
||||
if (profile == null) {
|
||||
AuthManager.setSelectedProfileId(null)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
selectedProfile = null,
|
||||
profileName = "",
|
||||
token = "",
|
||||
host = "127.0.0.1",
|
||||
port = "9119",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AuthManager.setSelectedProfileId(profile.id)
|
||||
val token = AuthManager.getProfileToken(profile.id) ?: ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
selectedProfile = profile,
|
||||
profileName = profile.name,
|
||||
host = profile.host,
|
||||
port = profile.port.toString(),
|
||||
token = token,
|
||||
)
|
||||
}
|
||||
fun selectProfile(profile: ConnectionProfile) {
|
||||
AuthManager.setSelectedProfileId(profile.id)
|
||||
val token = AuthManager.getProfileToken(profile.id) ?: ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
selectedProfile = profile,
|
||||
profileName = profile.name,
|
||||
host = profile.host,
|
||||
port = profile.port.toString(),
|
||||
token = token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +124,9 @@ class ConnectViewModel(
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
// Only persist credentials to global state upon successful verification
|
||||
// Persist credentials to the selected (Default) profile upon successful verification.
|
||||
// setToken/setHost/setPort delegate to the currently-selected profile, which is
|
||||
// always non-null after issue #478 (never the legacy standalone path).
|
||||
AuthManager.setToken(state.token)
|
||||
AuthManager.setHost(state.host)
|
||||
AuthManager.setPort(port)
|
||||
@@ -177,11 +161,10 @@ class ConnectViewModel(
|
||||
AuthManager.setSelectedProfileId(targetProfile.id)
|
||||
AuthManager.setProfileToken(targetProfile.id, state.token)
|
||||
} else {
|
||||
// Clear selected profile if we connected standalone and didn't check "save profile"
|
||||
AuthManager.setSelectedProfileId(null)
|
||||
AuthManager.setToken(state.token)
|
||||
AuthManager.setHost(state.host)
|
||||
AuthManager.setPort(port)
|
||||
// No explicit profile name — store the connection on the default profile.
|
||||
AuthManager.ensureDefaultSelected()
|
||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
||||
AuthManager.setProfileToken(AuthManager.DEFAULT_PROFILE_ID, state.token)
|
||||
}
|
||||
ApiClient.rebuild()
|
||||
_uiState.update {
|
||||
|
||||
@@ -359,13 +359,6 @@ private fun ConnectionSection(
|
||||
onDismissRequest = { profilesExpanded = false },
|
||||
modifier = Modifier.fillMaxWidth(0.85f),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.settings_profile_none)) },
|
||||
onClick = {
|
||||
viewModel.selectProfile(null)
|
||||
profilesExpanded = false
|
||||
},
|
||||
)
|
||||
state.profiles.forEach { profile ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(profile.name) },
|
||||
|
||||
@@ -127,8 +127,13 @@ class SettingsViewModel(
|
||||
val updatedProfiles = AuthManager.getConnectionProfiles().filter { it.id != profileId }
|
||||
AuthManager.saveConnectionProfiles(updatedProfiles)
|
||||
AuthManager.setProfileToken(profileId, null)
|
||||
// Never leave selection null (issue #478): if the deleted profile was selected,
|
||||
// fall back to the default profile instead of clearing selection.
|
||||
if (AuthManager.getSelectedProfileId() == profileId) {
|
||||
AuthManager.setSelectedProfileId(null)
|
||||
if (updatedProfiles.none { it.id == AuthManager.DEFAULT_PROFILE_ID }) {
|
||||
AuthManager.ensureDefaultProfile()
|
||||
}
|
||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
||||
}
|
||||
viewModelScope.launch(ioDispatcher) { loadSettings() }
|
||||
ApiClient.rebuild()
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
<string name="settings_sec_connection">Connection</string>
|
||||
<string name="settings_item_profile">Connection Profile</string>
|
||||
<string name="settings_profile_default">Standalone / Default</string>
|
||||
<string name="settings_profile_none">None (Standalone / Default)</string>
|
||||
<string name="settings_action_rename_profile">Rename Profile</string>
|
||||
<string name="settings_field_host">Host</string>
|
||||
<string name="settings_field_port">Port</string>
|
||||
@@ -189,7 +188,6 @@
|
||||
<!-- Connect Screen -->
|
||||
<string name="connect_selected_profile">Profile: %1$s</string>
|
||||
<string name="connect_action_select_profile">Select Saved Profile</string>
|
||||
<string name="connect_profile_none">None (Standalone)</string>
|
||||
|
||||
<!-- Connect Error Messages -->
|
||||
<string name="connect_error_token_required">Token is required</string>
|
||||
|
||||
@@ -107,6 +107,7 @@ class E2eIntegrationTest {
|
||||
// so only resources used by ConnectViewModel + enum labels need stubs.
|
||||
every { mockApp.getString(any<Int>()) } returns ""
|
||||
every { AuthManager.setSelectedProfileId(any()) } returns Unit
|
||||
every { AuthManager.ensureDefaultSelected() } returns Unit
|
||||
every { AuthManager.getProfileToken(any()) } returns null
|
||||
every { AuthManager.setProfileToken(any(), any()) } returns Unit
|
||||
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
|
||||
|
||||
@@ -29,6 +29,7 @@ class AuthManagerTest {
|
||||
private lateinit var mockPrefs: SharedPreferences
|
||||
private lateinit var mockEditor: SharedPreferences.Editor
|
||||
private lateinit var mockContext: Context
|
||||
private lateinit var testContext: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -52,7 +53,7 @@ class AuthManagerTest {
|
||||
|
||||
// Mock filesDir to point to temporary directory for DataStore
|
||||
val tempDir = java.io.File(System.getProperty("java.io.tmpdir"))
|
||||
val testContext = TestContext(tempDir, mockContext)
|
||||
testContext = TestContext(tempDir, mockContext)
|
||||
|
||||
val tempFile = java.io.File(tempDir, "server_store.json")
|
||||
if (tempFile.exists()) {
|
||||
@@ -110,21 +111,21 @@ class AuthManagerTest {
|
||||
|
||||
@Test
|
||||
fun testGetAndSetToken() {
|
||||
every { mockPrefs.getString("auth_token", null) } returns "dummy_token_123"
|
||||
AuthManager.ensureDefaultProfile()
|
||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
||||
AuthManager.setToken("dummy_token_123")
|
||||
assertEquals("dummy_token_123", AuthManager.getToken())
|
||||
|
||||
AuthManager.setToken("new_token")
|
||||
verify { mockEditor.putString("auth_token", "new_token") }
|
||||
verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", "dummy_token_123") }
|
||||
verify { mockEditor.apply() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetAndSetToken_null() {
|
||||
every { mockPrefs.getString("auth_token", null) } returns null
|
||||
assertNull(AuthManager.getToken())
|
||||
|
||||
AuthManager.ensureDefaultProfile()
|
||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
||||
AuthManager.setToken(null)
|
||||
verify { mockEditor.putString("auth_token", null) }
|
||||
assertNull(AuthManager.getToken())
|
||||
verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) }
|
||||
verify { mockEditor.apply() }
|
||||
}
|
||||
|
||||
@@ -155,15 +156,17 @@ class AuthManagerTest {
|
||||
|
||||
@Test
|
||||
fun testTokenCaching() {
|
||||
every { mockPrefs.getString("auth_token", null) } returns "first-token"
|
||||
AuthManager.ensureDefaultProfile()
|
||||
every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "first-token"
|
||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
||||
assertEquals("first-token", AuthManager.getToken())
|
||||
|
||||
// Update the mockPrefs directly, but since getToken uses the cache, it should still return the first token
|
||||
every { mockPrefs.getString("auth_token", null) } returns "second-token"
|
||||
every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "second-token"
|
||||
assertEquals("first-token", AuthManager.getToken())
|
||||
|
||||
// Clear token initialized manually since this happens upon selecting profile id
|
||||
AuthManager.setSelectedProfileId(null)
|
||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
||||
assertEquals("second-token", AuthManager.getToken())
|
||||
|
||||
// Test setToken
|
||||
@@ -173,17 +176,21 @@ class AuthManagerTest {
|
||||
|
||||
@Test
|
||||
fun testWsUrl() {
|
||||
AuthManager.ensureDefaultProfile()
|
||||
every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "token123"
|
||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
||||
AuthManager.setHost("hermes.local")
|
||||
AuthManager.setPort(1234)
|
||||
every { mockPrefs.getString("auth_token", null) } returns "token123"
|
||||
assertEquals("ws://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWsUrl_nullToken() {
|
||||
AuthManager.ensureDefaultProfile()
|
||||
every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns null
|
||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
||||
AuthManager.setHost("hermes.local")
|
||||
AuthManager.setPort(1234)
|
||||
every { mockPrefs.getString("auth_token", null) } returns null
|
||||
assertEquals("ws://hermes.local:1234/api/ws?token=", AuthManager.wsUrl())
|
||||
}
|
||||
|
||||
@@ -199,19 +206,11 @@ class AuthManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetToken_fallsBackToGlobalWhenProfileTokenMissing() {
|
||||
fun testGetToken_returnsNullWhenProfileHasNoToken() {
|
||||
val profile1 = ConnectionProfile("prof-1", "Profile 1", "127.0.0.1", 9119)
|
||||
AuthManager.saveConnectionProfiles(listOf(profile1))
|
||||
every { mockPrefs.getString("token_prof-1", null) } returns null // no profile token
|
||||
every { mockPrefs.getString("auth_token", null) } returns "global-token"
|
||||
every { mockPrefs.getString("token_prof-1", null) } returns null
|
||||
AuthManager.setSelectedProfileId("prof-1")
|
||||
assertEquals("global-token", AuthManager.getToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetToken_returnsNullWhenNoProfileAndNoGlobal() {
|
||||
every { mockPrefs.getString("auth_token", null) } returns null
|
||||
AuthManager.setSelectedProfileId(null)
|
||||
assertNull(AuthManager.getToken())
|
||||
}
|
||||
|
||||
@@ -225,18 +224,19 @@ class AuthManagerTest {
|
||||
|
||||
verify { mockEditor.putString("token_prof-1", "new-profile-token") }
|
||||
verify { mockEditor.apply() }
|
||||
// Should NOT write to global token
|
||||
// Should NOT write to a global token key
|
||||
verify(exactly = 0) { mockEditor.putString("auth_token", any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSetToken_writesToGlobalWhenNoSelectedProfile() {
|
||||
AuthManager.setSelectedProfileId(null)
|
||||
fun testSetToken_writesToDefaultProfileWhenNoSelection() {
|
||||
AuthManager.ensureDefaultProfile()
|
||||
|
||||
AuthManager.setToken("global-token")
|
||||
AuthManager.setToken("default-token")
|
||||
|
||||
verify { mockEditor.putString("auth_token", "global-token") }
|
||||
verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", "default-token") }
|
||||
verify { mockEditor.apply() }
|
||||
verify(exactly = 0) { mockEditor.putString("auth_token", any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -259,6 +259,7 @@ class AuthManagerTest {
|
||||
|
||||
@Test
|
||||
fun testGetHost_fallsBackToDefaultWhenSelectedProfileIdNotFound() {
|
||||
AuthManager.ensureDefaultProfile()
|
||||
AuthManager.setSelectedProfileId("nonexistent")
|
||||
AuthManager.saveConnectionProfiles(emptyList())
|
||||
AuthManager.setHost("192.168.1.1")
|
||||
@@ -292,7 +293,7 @@ class AuthManagerTest {
|
||||
|
||||
@Test
|
||||
fun testProfileTokenRoundTrip() {
|
||||
AuthManager.setSelectedProfileId(null)
|
||||
AuthManager.ensureDefaultProfile()
|
||||
AuthManager.setProfileToken("prof-a", "token-a")
|
||||
|
||||
verify { mockEditor.putString("token_prof-a", "token-a") }
|
||||
@@ -343,22 +344,20 @@ class AuthManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetToken_fallsBackToGlobalWhenSelectedProfileLacksToken() {
|
||||
fun testGetToken_profileWithNoTokenReturnsNull() {
|
||||
val profileA = ConnectionProfile("prof-a", "Profile A", "127.0.0.1", 9119)
|
||||
val profileB = ConnectionProfile("prof-b", "Profile B", "127.0.0.1", 9119)
|
||||
AuthManager.saveConnectionProfiles(listOf(profileA, profileB))
|
||||
|
||||
// Profile A is selected but has no token → falls back to global
|
||||
// Profile A is selected but has no token → null (no global fallback)
|
||||
every { mockPrefs.getString("token_prof-a", null) } returns null
|
||||
every { mockPrefs.getString("auth_token", null) } returns "shared-global-token"
|
||||
AuthManager.setSelectedProfileId("prof-a")
|
||||
assertNull(AuthManager.getToken())
|
||||
|
||||
assertEquals("shared-global-token", AuthManager.getToken())
|
||||
|
||||
// Switch to Profile B, which also has no token → same global fallback
|
||||
// Switch to Profile B, which also has no token → still null
|
||||
every { mockPrefs.getString("token_prof-b", null) } returns null
|
||||
AuthManager.setSelectedProfileId("prof-b")
|
||||
assertEquals("shared-global-token", AuthManager.getToken())
|
||||
assertNull(AuthManager.getToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -373,11 +372,10 @@ class AuthManagerTest {
|
||||
AuthManager.setSelectedProfileId("prof-a")
|
||||
assertEquals("token-a", AuthManager.getToken())
|
||||
|
||||
// Profile B has no token, but global exists
|
||||
// Profile B has no token → null
|
||||
every { mockPrefs.getString("token_prof-b", null) } returns null
|
||||
every { mockPrefs.getString("auth_token", null) } returns "fallback"
|
||||
AuthManager.setSelectedProfileId("prof-b")
|
||||
assertEquals("fallback", AuthManager.getToken())
|
||||
assertNull(AuthManager.getToken())
|
||||
|
||||
// Profile C has its own token too
|
||||
every { mockPrefs.getString("token_prof-c", null) } returns "token-c"
|
||||
@@ -427,4 +425,52 @@ class AuthManagerTest {
|
||||
AuthManager.setSelectedProfileId(" ")
|
||||
assertNull(AuthManager.getSelectedProfileId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEnsureDefaultProfile_createsDefaultWhenMissing() {
|
||||
AuthManager.saveConnectionProfiles(emptyList())
|
||||
AuthManager.setSelectedProfileId(null)
|
||||
|
||||
AuthManager.ensureDefaultProfile()
|
||||
|
||||
val profiles = AuthManager.getConnectionProfiles()
|
||||
assertEquals(1, profiles.size)
|
||||
assertEquals(AuthManager.DEFAULT_PROFILE_ID, profiles[0].id)
|
||||
assertEquals(AuthManager.DEFAULT_PROFILE_NAME, profiles[0].name)
|
||||
assertEquals(AuthManager.DEFAULT_PROFILE_ID, AuthManager.getSelectedProfileId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEnsureDefaultProfile_doesNotDuplicateExisting() {
|
||||
val existing = ConnectionProfile("prof-1", "Work", "10.0.0.1", 9119)
|
||||
AuthManager.saveConnectionProfiles(listOf(existing))
|
||||
AuthManager.setSelectedProfileId("prof-1")
|
||||
|
||||
AuthManager.ensureDefaultProfile()
|
||||
|
||||
val profiles = AuthManager.getConnectionProfiles()
|
||||
assertEquals(1, profiles.size)
|
||||
assertEquals("prof-1", AuthManager.getSelectedProfileId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrateLegacyToken_foldsIntoDefaultProfile() {
|
||||
// Simulate a legacy install: no profiles, a global auth_token, and a selected id of null.
|
||||
every { mockPrefs.getString("auth_token", null) } returns "legacy-standalone-token"
|
||||
every { mockPrefs.getBoolean("legacy_default_migrated", false) } returns false
|
||||
|
||||
// Re-initialize so init() runs the one-time migration
|
||||
AuthManager.resetAuthStateForTest()
|
||||
AuthManager.init(testContext)
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val field = AuthManager::class.java.getDeclaredField("prefsDeferred")
|
||||
field.isAccessible = true
|
||||
(field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>)?.await()
|
||||
}
|
||||
|
||||
// token should now be persisted under the default profile key, and the legacy key removed
|
||||
verify { mockEditor.putString("token_${AuthManager.DEFAULT_PROFILE_ID}", "legacy-standalone-token") }
|
||||
verify { mockEditor.remove("auth_token") }
|
||||
verify { mockEditor.putBoolean("legacy_default_migrated", true) }
|
||||
}
|
||||
}
|
||||
@@ -410,21 +410,6 @@ class ConnectViewModelTest {
|
||||
verify { AuthManager.setSelectedProfileId("test-id") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectProfile_null_clearsState() {
|
||||
val viewModel = ConnectViewModel(mockApp)
|
||||
viewModel.selectProfile(null)
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertEquals("", state.profileName)
|
||||
assertEquals("127.0.0.1", state.host)
|
||||
assertEquals("9119", state.port)
|
||||
assertEquals("", state.token)
|
||||
assertNull(state.selectedProfile)
|
||||
|
||||
verify { AuthManager.setSelectedProfileId(null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConnect_saveProfile_savesAndSelects() =
|
||||
runTest {
|
||||
@@ -525,7 +510,7 @@ class ConnectViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConnect_withoutSaveProfile_clearsSelectedProfile() =
|
||||
fun testConnect_withoutSaveProfile_persistsToDefaultProfile() =
|
||||
runTest {
|
||||
val viewModel = ConnectViewModel(mockApp)
|
||||
viewModel.onTokenChange("standalone-token")
|
||||
@@ -548,9 +533,9 @@ class ConnectViewModelTest {
|
||||
viewModel.connect()
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { AuthManager.setSelectedProfileId(null) }
|
||||
verify { AuthManager.setToken("standalone-token") }
|
||||
verify { AuthManager.setHost("10.0.0.1") }
|
||||
verify { AuthManager.setPort(9119) }
|
||||
verify(exactly = 0) { AuthManager.saveConnectionProfiles(any()) }
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@@ -133,18 +132,18 @@ class SettingsViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectProfile_nullClearsSelection() {
|
||||
fun testSelectProfile_nullFallsBackToDefault() {
|
||||
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
||||
storedSelectedProfileId = "prof-1"
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.selectProfile(null)
|
||||
viewModel.selectProfile(AuthManager.DEFAULT_PROFILE_ID)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
verify { AuthManager.setSelectedProfileId(null) }
|
||||
verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) }
|
||||
verify { ApiClient.rebuild() }
|
||||
assertNull(viewModel.uiState.value.selectedProfileId)
|
||||
assertEquals(AuthManager.DEFAULT_PROFILE_ID, viewModel.uiState.value.selectedProfileId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -199,12 +198,12 @@ class SettingsViewModelTest {
|
||||
|
||||
verify { AuthManager.saveConnectionProfiles(any()) }
|
||||
verify { AuthManager.setProfileToken("prof-1", null) }
|
||||
verify { AuthManager.setSelectedProfileId(null) }
|
||||
verify { AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID) }
|
||||
verify { ApiClient.rebuild() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteProfile_nonSelected_doesNotClearSelectedId() {
|
||||
fun testDeleteProfile_nonSelected_doesNotChangeSelectedId() {
|
||||
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
||||
every { AuthManager.getSelectedProfileId() } returns "prof-1"
|
||||
|
||||
@@ -214,8 +213,8 @@ class SettingsViewModelTest {
|
||||
|
||||
verify { AuthManager.saveConnectionProfiles(any()) }
|
||||
verify { AuthManager.setProfileToken("prof-2", null) }
|
||||
// Selected profile (prof-1) was NOT deleted — should NOT clear selection
|
||||
verify(exactly = 0) { AuthManager.setSelectedProfileId(null) }
|
||||
// Selected profile (prof-1) was NOT deleted — should NOT change selection
|
||||
verify(exactly = 0) { AuthManager.setSelectedProfileId(any()) }
|
||||
verify { ApiClient.rebuild() }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user