Keep DNS retry safe for WebSockets and preserve saved user edits
All checks were successful
review/security-claude Blocking finding fixed on 052d0b88; delta reviewed

This commit is contained in:
justinzeus 2026-11-07 01:23:33 +01:00
parent 327f631f0d
commit 052d0b8898
10 changed files with 144 additions and 17 deletions

View file

@ -51,6 +51,10 @@ interface JellyfinServerDao {
return user.copy(rowId = result.toInt())
}
/** Network name refreshes must not overwrite settings or protection changed since launch. */
@Query("UPDATE users SET name = :name WHERE serverId = :serverId AND id = :userId")
fun updateUserName(serverId: UUID, userId: UUID, name: String?): Int
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
fun getUser(
serverId: UUID,

View file

@ -138,7 +138,8 @@ class ServerRepository
val (server, userDto) = readSession(expected.server)
if (_current.value !== expected || userDto.id != expected.user.id) return@withContext
serverDao.addOrUpdateServer(server)
val user = serverDao.addOrUpdateUser(expected.user.copy(name = userDto.name))
serverDao.updateUserName(expected.server.id, expected.user.id, userDto.name)
val user = serverDao.getUser(expected.server.id, expected.user.id) ?: return@withContext
if (_current.compareAndSet(expected, CurrentUser(server, user))) updateUserDto(userDto)
}

View file

@ -97,11 +97,11 @@ object AppModule {
fun okHttpClient(dns: PersistentDns) =
DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build()
/** Resolve filesDir lazily: Android may create it and lookup always runs off main. */
/** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */
@Provides
@Singleton
fun dns(@ApplicationContext context: Context): PersistentDns =
PersistentDns(file = { File(context.filesDir, "wolfskin-dns.json") })
PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") })
@AuthOkHttpClient
@Provides

View file

@ -1,5 +1,8 @@
package com.github.damontecres.wholphin.services
import android.app.Application
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import android.content.Context
import com.github.damontecres.wholphin.data.*
import com.github.damontecres.wholphin.data.model.*
@ -20,20 +23,30 @@ import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
import org.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Saved identities can paint offline without unlocking protected logins or accepting a late identity. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ServerSavedSessionTest {
private val dispatcher = StandardTestDispatcher()
private val server = JellyfinServer(UUID.randomUUID(), "Saved server", "https://jellyfin.test", "12.1.0")
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Saved name", accessToken = "saved-token")
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Saved name", accessToken = "saved-token")
private val users = mockk<UserApi>()
private val system = mockk<SystemApi>()
private val api = mockk<ApiClient>(relaxed = true)
private val recent = mockk<MostRecentServerProvider>()
private val dao = mockk<JellyfinServerDao>(relaxed = true)
private val database by lazy {
Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext<Application>(), AppDatabase::class.java)
.allowMainThreadQueries().build()
}
private val dao by lazy { database.serverDao() }
private val dto = UserDto(id = user.id, name = "Refreshed name",
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false)
private val info = PublicSystemInfo(serverName = "Refreshed server", version = "12.1.0")
@ -41,14 +54,16 @@ class ServerSavedSessionTest {
@Before fun savedLogin() {
every { recent.get() } returns MostRecentServer.from(CurrentUser(server, user))
every { dao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
every { dao.addOrUpdateUser(any()) } answers { firstArg() }
dao.addOrUpdateServer(server)
user = dao.addOrUpdateUser(user)
every { api.userApi } returns users
every { api.systemApi } returns system
coEvery { users.getCurrentUser() } returns successResponse(dto)
coEvery { system.getPublicSystemInfo() } returns successResponse(info)
}
@After fun closeDatabase() { database.close() }
@Test fun savedSessionIsUsableWhileUserRevalidationWaitsAndSystemInfoRunsInParallel() = runTest(dispatcher) {
val pendingUser = CompletableDeferred<Unit>()
val systemStarted = CompletableDeferred<Unit>()
@ -69,7 +84,7 @@ class ServerSavedSessionTest {
@Test fun savedProtectedPeopleRemainLocked() = runTest(dispatcher) {
for (protected in listOf(user.copy(pin = "1234"), user.copy(requireLogin = true))) {
every { dao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(protected))
dao.updateUser(protected)
assertTrue(repository.restoreSavedSession() is RestoredSession.ServerOnly)
assertNull(repository.current.value)
}
@ -82,9 +97,8 @@ class ServerSavedSessionTest {
coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) }
val refresh = async { repository.revalidateSession(first) }
entered.await()
val selected = user.copy(id = UUID.randomUUID(), name = "Other person")
val selected = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Other person"))
every { recent.get() } returns MostRecentServer.from(CurrentUser(server, selected))
every { dao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(selected))
repository.restoreSavedSession()
pending.complete(Unit)
refresh.await()
@ -92,6 +106,26 @@ class ServerSavedSessionTest {
assertNull(repository.currentUserDto)
}
@Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) {
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
val entered = CompletableDeferred<Unit>()
val pending = CompletableDeferred<Unit>()
coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) }
val refresh = async { repository.revalidateSession(first) }
entered.await()
val edited = user.copy(
pin = "4567",
appPreferences = user.appPreferences.copy(preferredAudioLanguage = "nld"),
config = user.config.copy(homeSettingsSource = HomePageSettingsSource.LOCAL),
)
dao.updateUser(edited)
pending.complete(Unit)
refresh.await()
val expected = edited.copy(name = dto.name)
assertEquals(expected, dao.getUser(server.id, user.id))
assertEquals(expected, repository.current.value!!.user)
}
@Test fun anotherUsersResponseCannotRenameTheSavedPerson() = runTest(dispatcher) {
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
coEvery { users.getCurrentUser() } returns successResponse(dto.copy(id = UUID.randomUUID()))

View file

@ -6,7 +6,6 @@ import com.github.damontecres.wholphin.data.MostRecentServer
import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import dagger.hilt.android.qualifiers.ApplicationContext
import java.net.UnknownHostException
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@ -40,7 +39,7 @@ class NetworkStartup @Inject constructor(
private fun warm(url: String?) {
val host = url?.toHttpUrlOrNull()?.host ?: return
try { dns.lookup(host) } catch (_: UnknownHostException) {
try { dns.lookup(host) } catch (_: Exception) {
// An offline launch must still render the saved people.
}
}