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
14 changes: 13 additions & 1 deletion app/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,7 @@ paths:
tags:
- DangerZone
summary: Danger Zone パスワード照合
description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する
description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する。接続元ごとに連続失敗を数え、上限に達している間は429を返す
operationId: verifyDangerZonePassword
requestBody:
required: true
Expand All @@ -744,6 +744,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: 連続失敗が上限に達したため一時的に照合を受け付けない
headers:
Retry-After:
description: 再試行できるまでの秒数
schema:
type: integer
example: 60
content:
application/json:
schema:
$ref: '#/components/schemas/DangerZoneVerifyResponse'

# APK
/api/apk/version/latest:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package info.nukoneko.cuc.android.kidspos.api

class DangerZoneRateLimitedException(
val retryAfterSeconds: Long?
) : Exception("Danger zone verification is rate limited")
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ class OpenApiDangerZoneService(
val response = dangerZoneApi.verifyDangerZonePassword(
VerifyDangerZonePasswordRequest(password = password)
)
if (response.code() == HTTP_TOO_MANY_REQUESTS) {
throw DangerZoneRateLimitedException(
response.headers()[RETRY_AFTER_HEADER]?.trim()?.toLongOrNull()
)
}
if (!response.isSuccessful) {
throw Exception("Failed to verify danger zone password: ${response.code()}")
}
Expand All @@ -30,4 +35,9 @@ class OpenApiDangerZoneService(
message = body.message
)
}

private companion object {
const val HTTP_TOO_MANY_REQUESTS = 429
const val RETRY_AFTER_HEADER = "Retry-After"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ private fun DangerZoneSection(
Text(error.message)
}

is DangerZoneError.RateLimited -> {
Spacer(modifier = Modifier.padding(4.dp))
val seconds = error.retryAfterSeconds
Text(
if (seconds != null) {
stringResource(R.string.danger_zone_rate_limited, seconds)
} else {
stringResource(R.string.danger_zone_rate_limited_unknown)
}
)
}

is DangerZoneError.Unreachable -> {
Spacer(modifier = Modifier.padding(4.dp))
Text(stringResource(R.string.danger_zone_verify_failed))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import info.nukoneko.cuc.android.kidspos.BuildConfig
import info.nukoneko.cuc.android.kidspos.api.DangerZoneRateLimitedException
import info.nukoneko.cuc.android.kidspos.data.repository.AppUpdateRepository
import info.nukoneko.cuc.android.kidspos.data.repository.DangerZoneRepository
import info.nukoneko.cuc.android.kidspos.data.settings.SettingsRepository
Expand Down Expand Up @@ -51,6 +52,7 @@ sealed interface DangerZoneStatus {

sealed interface DangerZoneError {
data class Rejected(val message: String) : DangerZoneError
data class RateLimited(val retryAfterSeconds: Long?) : DangerZoneError
data object Unreachable : DangerZoneError
}

Expand Down Expand Up @@ -138,6 +140,9 @@ class SettingsViewModel @Inject constructor(
!result.configured -> DangerZoneStatus.Unlocked(DangerZoneReason.NOT_CONFIGURED)
else -> DangerZoneStatus.Locked(DangerZoneError.Rejected(result.message))
}
} catch (e: DangerZoneRateLimitedException) {
Timber.w(e, "Danger zone verification is rate limited")
DangerZoneStatus.Locked(DangerZoneError.RateLimited(e.retryAfterSeconds))
} catch (e: Exception) {
Timber.w(e, "Failed to verify danger zone password")
DangerZoneStatus.Locked(DangerZoneError.Unreachable)
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,6 @@
<string name="danger_zone_not_configured">サーバーにパスワードが設定されていないため、そのまま操作できます</string>
<string name="danger_zone_status_failed">サーバーに接続できないため、そのまま操作できます</string>
<string name="danger_zone_verify_failed">サーバーに接続できませんでした</string>
<string name="danger_zone_rate_limited">試行回数が多すぎます。%1$d秒後にもう一度お試しください</string>
<string name="danger_zone_rate_limited_unknown">試行回数が多すぎます。しばらく待ってからもう一度お試しください</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package info.nukoneko.cuc.android.kidspos.api

import info.nukoneko.cuc.android.kidspos.api.generated.DangerZoneApi
import info.nukoneko.cuc.android.kidspos.api.generated.model.DangerZoneStatusResponse
import info.nukoneko.cuc.android.kidspos.api.generated.model.DangerZoneVerifyResponse
import info.nukoneko.cuc.android.kidspos.api.generated.model.VerifyDangerZonePasswordRequest
import kotlinx.coroutines.test.runTest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response

class OpenApiDangerZoneServiceTest {

private class FakeDangerZoneApi(
private val verifyResponse: Response<DangerZoneVerifyResponse>
) : DangerZoneApi {
override suspend fun getDangerZoneStatus(): Response<DangerZoneStatusResponse> =
Response.success(DangerZoneStatusResponse(configured = true))

override suspend fun verifyDangerZonePassword(
verifyDangerZonePasswordRequest: VerifyDangerZonePasswordRequest
): Response<DangerZoneVerifyResponse> = verifyResponse
}

private fun tooManyRequests(retryAfter: String?): Response<DangerZoneVerifyResponse> {
val raw = okhttp3.Response.Builder()
.request(Request.Builder().url("http://localhost/api/setting/danger-zone/verify").build())
.protocol(Protocol.HTTP_1_1)
.code(429)
.message("Too Many Requests")
.apply { retryAfter?.let { header("Retry-After", it) } }
.build()
return Response.error("{}".toResponseBody("application/json".toMediaType()), raw)
}

private fun serviceWith(response: Response<DangerZoneVerifyResponse>) =
OpenApiDangerZoneService(FakeDangerZoneApi(response))

@Test
fun successfulVerifyIsMappedToVerification() = runTest {
val service = serviceWith(
Response.success(
DangerZoneVerifyResponse(valid = true, configured = true, message = "認証しました")
)
)

val result = service.verifyPassword("secret")

assertEquals(true, result.valid)
assertEquals(true, result.configured)
assertEquals("認証しました", result.message)
}

@Test
fun tooManyRequestsRaisesRateLimitedWithRetryAfter() = runTest {
val service = serviceWith(tooManyRequests("45"))

val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()

assertTrue(error is DangerZoneRateLimitedException)
assertEquals(45L, (error as DangerZoneRateLimitedException).retryAfterSeconds)
}

@Test
fun tooManyRequestsWithoutRetryAfterRaisesRateLimitedWithoutSeconds() = runTest {
val service = serviceWith(tooManyRequests(null))

val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()

assertTrue(error is DangerZoneRateLimitedException)
assertNull((error as DangerZoneRateLimitedException).retryAfterSeconds)
}

@Test
fun tooManyRequestsWithHttpDateRetryAfterRaisesRateLimitedWithoutSeconds() = runTest {
val service = serviceWith(tooManyRequests("Wed, 21 Oct 2015 07:28:00 GMT"))

val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()

assertTrue(error is DangerZoneRateLimitedException)
assertNull((error as DangerZoneRateLimitedException).retryAfterSeconds)
}

@Test
fun otherErrorStatusRaisesGenericFailure() = runTest {
val raw = okhttp3.Response.Builder()
.request(Request.Builder().url("http://localhost/api/setting/danger-zone/verify").build())
.protocol(Protocol.HTTP_1_1)
.code(500)
.message("Internal Server Error")
.build()
val service = serviceWith(
Response.error("{}".toResponseBody("application/json".toMediaType()), raw)
)

val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()

assertTrue(error != null && error !is DangerZoneRateLimitedException)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import info.nukoneko.cuc.android.kidspos.R
import info.nukoneko.cuc.android.kidspos.api.DangerZoneRateLimitedException
import info.nukoneko.cuc.android.kidspos.entity.AppUpdate
import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
import info.nukoneko.cuc.android.kidspos.testutil.FakeAppUpdateService
Expand Down Expand Up @@ -189,6 +190,59 @@ class SettingsScreenTest {
composeRule.onNodeWithText(context.getString(R.string.load_setting)).assertDoesNotExist()
}

@Test
fun rateLimitedVerifyShowsRetryAfterMessage() {
val dangerZoneService = FakeDangerZoneService()
dangerZoneService.isPasswordConfiguredHandler = { true }
dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(45) }
composeRule.setContent {
SettingsScreen(
onNavigateBack = {},
viewModel = createSettingsViewModel(
settingsRepository,
dangerZoneService = dangerZoneService
)
)
}

composeRule.onNode(hasSetTextAction()).performTextInput("wrong")
composeRule.onNodeWithText(context.getString(R.string.danger_zone_unlock))
.performScrollTo()
.performClick()
composeRule.waitForIdle()

composeRule.onNodeWithText(
context.getString(R.string.danger_zone_rate_limited, 45L)
).assertExists()
composeRule.onNodeWithText(context.getString(R.string.load_setting)).assertDoesNotExist()
}

@Test
fun rateLimitedVerifyWithoutRetryAfterShowsGenericMessage() {
val dangerZoneService = FakeDangerZoneService()
dangerZoneService.isPasswordConfiguredHandler = { true }
dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(null) }
composeRule.setContent {
SettingsScreen(
onNavigateBack = {},
viewModel = createSettingsViewModel(
settingsRepository,
dangerZoneService = dangerZoneService
)
)
}

composeRule.onNode(hasSetTextAction()).performTextInput("wrong")
composeRule.onNodeWithText(context.getString(R.string.danger_zone_unlock))
.performScrollTo()
.performClick()
composeRule.waitForIdle()

composeRule.onNodeWithText(
context.getString(R.string.danger_zone_rate_limited_unknown)
).assertExists()
}

@Test
fun availableUpdateShowsConfirmDialog() {
val updateService = FakeAppUpdateService()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package info.nukoneko.cuc.android.kidspos.ui.settings

import info.nukoneko.cuc.android.kidspos.api.DangerZoneRateLimitedException
import info.nukoneko.cuc.android.kidspos.entity.AppUpdate
import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
import info.nukoneko.cuc.android.kidspos.testutil.FakeApkDownloader
Expand Down Expand Up @@ -325,6 +326,45 @@ class SettingsViewModelTest {
)
}

@Test
fun rateLimitedVerifyKeepsDangerZoneLockedWithRetryAfter() = runTest {
val dangerZoneService = FakeDangerZoneService()
dangerZoneService.isPasswordConfiguredHandler = { true }
dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(45) }
val viewModel = createSettingsViewModel(
settingsRepository,
dangerZoneService = dangerZoneService
)

viewModel.onDangerZonePasswordChange("wrong")
viewModel.onUnlockDangerZone()

assertEquals(
DangerZoneStatus.Locked(DangerZoneError.RateLimited(45)),
viewModel.uiState.value.dangerZoneStatus
)
assertEquals("wrong", viewModel.uiState.value.dangerZonePassword)
}

@Test
fun rateLimitedVerifyWithoutRetryAfterKeepsDangerZoneLocked() = runTest {
val dangerZoneService = FakeDangerZoneService()
dangerZoneService.isPasswordConfiguredHandler = { true }
dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(null) }
val viewModel = createSettingsViewModel(
settingsRepository,
dangerZoneService = dangerZoneService
)

viewModel.onDangerZonePasswordChange("wrong")
viewModel.onUnlockDangerZone()

assertEquals(
DangerZoneStatus.Locked(DangerZoneError.RateLimited(null)),
viewModel.uiState.value.dangerZoneStatus
)
}

@Test
fun passwordClearedOnServerUnlocksDangerZone() = runTest {
val dangerZoneService = FakeDangerZoneService()
Expand Down
Loading