Scope artwork credentials and preserve library recovery
This commit is contained in:
parent
09f1d72f30
commit
34ea983a51
9 changed files with 263 additions and 11 deletions
|
|
@ -0,0 +1,70 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Conductarr metadata failure cannot revoke libraries granted by Jellyfin. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class ConductarrAudienceRecoveryTest {
|
||||
@Before fun immediateMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
|
||||
@After fun restoreMain() { Dispatchers.resetMain() }
|
||||
|
||||
@Test fun operatorSuccessWithFailedOrEmptyAudiencesKeepsTheNameRuleAndForegroundCanHeal() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
ConductarrPreference.initialize(context)
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
for (empty in listOf(false, true)) FakeConductarr().use { fake ->
|
||||
fake.audiencesStatus = if (empty) 200 else 503
|
||||
fake.emptyAudiences = empty
|
||||
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||
session.authenticate()
|
||||
assertTrue(session.identity.value!!.signedIn)
|
||||
val client = session.identity.value!!.client
|
||||
val libraries = LibraryReadFacade(mockk())
|
||||
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||
try {
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
assertNull(libraries.audiences)
|
||||
assertEquals("movies", libraries.destination("Movies", CollectionType.MOVIES))
|
||||
assertEquals("shows", libraries.destination("Shows", CollectionType.TVSHOWS))
|
||||
assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS))
|
||||
if (!empty) {
|
||||
fake.audiencesStatus = 200
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
assertEquals(listOf("Shared Anime"), libraries.audiences!!.single().libraries)
|
||||
assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS))
|
||||
assertSame(client, session.identity.value!!.client)
|
||||
}
|
||||
} finally { model.viewModelScope.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ class ConductarrProjectionTest {
|
|||
ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client ->
|
||||
val provider = ConductarrProvider(client)
|
||||
assertEquals("Shared", provider.profiles()!!.profiles!!.first().name)
|
||||
assertEquals(listOf("Shared Anime"), provider.audiences().single().libraries)
|
||||
assertEquals(listOf("Shared Anime"), provider.audiences()!!.single().libraries)
|
||||
val snapshot = provider.discover("shared").last()
|
||||
assertEquals(DiscoverStatus.Ready, snapshot.status)
|
||||
assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ internal class FakeConductarr : Closeable {
|
|||
@Volatile var existingRequests = false
|
||||
@Volatile var notModified = false
|
||||
@Volatile var ratingsStatus = 200
|
||||
@Volatile var audiencesStatus = 200
|
||||
@Volatile var emptyAudiences = false
|
||||
@Volatile var seasonTwoScore = 8.1
|
||||
@Volatile var localArt = false
|
||||
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||
|
|
@ -61,9 +63,11 @@ internal class FakeConductarr : Closeable {
|
|||
else -> return response("", 404)
|
||||
}
|
||||
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
||||
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
|
||||
if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
||||
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
||||
var body = wire.getValue(key).jsonObject
|
||||
if (key == "audiences" && emptyAudiences) body = JsonObject(body + ("audiences" to JsonArray(emptyList())))
|
||||
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
|
||||
body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel")))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.imageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.Buffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.WolfskinImages
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** The shipped loader decodes both hosts, but only Jellyfin receives the injected credential. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class WolfskinArtworkTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun inheritedAuthenticatedFetcherCannotSendTheJellyfinTokenToAnotherArtworkHost() {
|
||||
MockWebServer().use { jellyfin ->
|
||||
MockWebServer().use { artwork ->
|
||||
jellyfin.start(); artwork.start()
|
||||
val context = rule.activity
|
||||
val credential = "MediaBrowser Client=\"Wolfskin test\", Token=\"$SHARED_TOKEN\""
|
||||
val authenticated = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
chain.proceed(chain.request().newBuilder().addHeader("Authorization", credential).build())
|
||||
}.build()
|
||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", jellyfin.url("/").toString(), null)
|
||||
val repository = mockk<ServerRepository> { every { currentServer } returns server }
|
||||
val network = WolfskinArtwork(authenticated, repository)
|
||||
val bytes = ByteArrayOutputStream().also {
|
||||
Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||
}.toByteArray()
|
||||
jellyfin.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build())
|
||||
artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build())
|
||||
artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build())
|
||||
val urls = listOf(jellyfin.url("/jellyfin.png"),
|
||||
artwork.url("/public.png").newBuilder().host("127.0.0.1").build(),
|
||||
artwork.url("/another-port.png"))
|
||||
val results = CopyOnWriteArrayList<coil3.request.ImageResult>()
|
||||
try {
|
||||
rule.setContent {
|
||||
CoilConfig(1024 * 1024L, authenticated, debugLogging = false, enableCache = false)
|
||||
LaunchedEffect(network) {
|
||||
// Force the upstream authenticated parent to exist before configuring Wolfskin.
|
||||
context.applicationContext.imageLoader
|
||||
network.configure(context)
|
||||
val loader = WolfskinImages.loader(context)
|
||||
for (url in urls) results.add(loader.execute(ImageRequest.Builder(context).data(url.toString())
|
||||
.memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED).build()))
|
||||
}
|
||||
}
|
||||
rule.waitUntil(30_000) {
|
||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
results.size == urls.size
|
||||
}
|
||||
results.forEach { result -> assertEquals(2, (result as SuccessResult).image.width) }
|
||||
assertEquals(credential, jellyfin.takeRequest().headers["Authorization"])
|
||||
assertNull(artwork.takeRequest().headers["Authorization"])
|
||||
assertNull(artwork.takeRequest().headers["Authorization"])
|
||||
} finally {
|
||||
WolfskinImages.loader(context).shutdown()
|
||||
authenticated.dispatcher.executorService.shutdown()
|
||||
authenticated.connectionPool.evictAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue