feat(app-phone): полноценный UI телефона — каталог Jellyfin, детали, пульт очков, реальное скачивание с S3
- 4 экрана по макету sketches/phone-ui: Каталог (табы Фильмы/Сериалы/Папки, поиск, бейджи зеркала), Детали (озвучки, статус конвертации, чипы скачивания), Очки (пульт, статус), Чат-заглушка - S3 SigV4 presign (S3Presigner): mirror-client read-only креды; watch() и DownloadManager подписывают URL (бакет приватный) - DownloadManager: реальное скачивание video+audio с прогрессом, readTimeout=0 (длинные файлы) - PhoneActions.watch: параметр presign (app-host работает без изменений) - TESTING.md: блок телефонных кейсов TP-1..6
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class S3PresignerTest {
|
||||
|
||||
private val url = "https://s3.binom.pw/media/mirror/m1/video.mkv"
|
||||
private val accessKey = "MirrorClient01QwertyKey"
|
||||
private val secretKey = "MirrorClient01SecretKeyValueForGlassesOnly"
|
||||
private val region = "us-east-1"
|
||||
private val expires = 3600
|
||||
private val amzDate = "20260819T000000Z"
|
||||
|
||||
@Test
|
||||
fun presignContainsAllXAmzParamsInAlphabeticalOrder() {
|
||||
val signed = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, amzDate)
|
||||
|
||||
assertTrue(signed.startsWith("$url?"), "presigned url должен начинаться с исходного url: $signed")
|
||||
val query = signed.substringAfter('?')
|
||||
assertTrue(query.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"))
|
||||
assertTrue(query.contains("X-Amz-Credential=$accessKey%2F20260819%2F$region%2Fs3%2Faws4_request"))
|
||||
assertTrue(query.contains("X-Amz-Date=$amzDate"))
|
||||
assertTrue(query.contains("X-Amz-Expires=$expires"))
|
||||
assertTrue(query.contains("X-Amz-SignedHeaders=host"))
|
||||
assertTrue(query.contains("X-Amz-Signature="))
|
||||
assertFalse(query.contains("&X-Amz-Expires=0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatureMatchesReference() {
|
||||
// эталон получен той же логикой (SigV4 presign GET, UNSIGNED-PAYLOAD, region us-east-1,
|
||||
// service s3, expires=3600, amzDate=20260819T000000Z) из Python-реализации, 19.08
|
||||
val signed = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, amzDate)
|
||||
assertEquals(
|
||||
"https://s3.binom.pw/media/mirror/m1/video.mkv" +
|
||||
"?X-Amz-Algorithm=AWS4-HMAC-SHA256" +
|
||||
"&X-Amz-Credential=MirrorClient01QwertyKey%2F20260819%2Fus-east-1%2Fs3%2Faws4_request" +
|
||||
"&X-Amz-Date=20260819T000000Z" +
|
||||
"&X-Amz-Expires=3600" +
|
||||
"&X-Amz-SignedHeaders=host" +
|
||||
"&X-Amz-Signature=c023bf37aabfbc224641090bdb4082fdf54033ca3a6ef07680b00b560c7b140f",
|
||||
signed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatureChangesWithTimeExpiresAndKey() {
|
||||
val base = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, amzDate)
|
||||
val sigBase = base.substringAfter("X-Amz-Signature=")
|
||||
|
||||
val otherTime = S3Presigner.presignGetAt(url, accessKey, secretKey, region, expires, "20260820T000000Z")
|
||||
assertFalse(otherTime.contains("X-Amz-Date=$amzDate"), "время должно попадать в подпись")
|
||||
|
||||
val otherExpires = S3Presigner.presignGetAt(url, accessKey, secretKey, region, 120, amzDate)
|
||||
assertTrue(otherExpires.contains("X-Amz-Expires=120"))
|
||||
|
||||
val otherKey = S3Presigner.presignGetAt(url, "OtherAccess", secretKey, region, expires, amzDate)
|
||||
assertFalse(
|
||||
otherKey.substringAfter("X-Amz-Signature=") == sigBase,
|
||||
"подпись должна зависеть от access key",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun presignGetProducesConsistentNowUrl() {
|
||||
val signed = S3Presigner.presignGet(url, accessKey, secretKey, region, expires)
|
||||
|
||||
assertTrue(signed.startsWith("$url?"))
|
||||
val query = signed.substringAfter('?')
|
||||
assertTrue(query.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"))
|
||||
assertTrue(query.contains("X-Amz-Credential="))
|
||||
assertTrue(query.contains("X-Amz-SignedHeaders=host"))
|
||||
assertTrue(query.contains("X-Amz-Expires=$expires"))
|
||||
|
||||
val amzDateNow = query.substringAfter("X-Amz-Date=").substringBefore("&X-Amz-Expires")
|
||||
assertTrue(
|
||||
Regex("^\\d{8}T\\d{6}Z$").matches(amzDateNow),
|
||||
"X-Amz-Date должен быть в формате yyyyMMdd'T'HHmmss'Z': $amzDateNow",
|
||||
)
|
||||
assertTrue(query.substringAfter("X-Amz-Signature=").let { it.length == 64 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user