Skip to content

Commit b09cbd1

Browse files
largeblueberrydavid-allison
authored andcommitted
test: verify login redirection on HTTP 429 using Robolectric
1 parent 7831634 commit b09cbd1

2 files changed

Lines changed: 190 additions & 115 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/SharedDecksActivity.kt

Lines changed: 120 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ import android.webkit.WebResourceResponse
2929
import android.webkit.WebView
3030
import android.webkit.WebViewClient
3131
import androidx.activity.OnBackPressedCallback
32+
import androidx.annotation.VisibleForTesting
3233
import androidx.core.os.bundleOf
3334
import androidx.fragment.app.commit
3435
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_INDEFINITE
35-
import com.ichi2.anki.common.annotations.NeedsTest
3636
import com.ichi2.anki.databinding.ActivitySharedDecksBinding
3737
import com.ichi2.anki.snackbar.showSnackbar
3838
import com.ichi2.anki.workarounds.SafeWebViewLayout
@@ -68,143 +68,148 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) {
6868
* History should not be cleared before the page finishes loading otherwise there would be
6969
* an extra entry in the history since the previous page would not get cleared.
7070
*/
71-
private val webViewClient =
72-
object : WebViewClient() {
73-
private var redirectTimes = 0
74-
75-
override fun doUpdateVisitedHistory(
76-
view: WebView?,
77-
url: String?,
78-
isReload: Boolean,
79-
) {
80-
super.doUpdateVisitedHistory(view, url, isReload)
81-
onBackPressedCallback.isEnabled = binding.webView.canGoBack()
82-
}
71+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
72+
internal inner class SharedDeckWebViewClient : WebViewClient() {
73+
private var redirectTimes = 0
74+
75+
override fun doUpdateVisitedHistory(
76+
view: WebView?,
77+
url: String?,
78+
isReload: Boolean,
79+
) {
80+
super.doUpdateVisitedHistory(view, url, isReload)
81+
onBackPressedCallback.isEnabled = binding.webView.canGoBack()
82+
}
8383

84-
override fun onPageFinished(
85-
view: WebView?,
86-
url: String?,
87-
) {
88-
// Clear history if mShouldHistoryBeCleared is true and set it to false
89-
if (shouldHistoryBeCleared) {
90-
binding.webView.clearHistory()
91-
shouldHistoryBeCleared = false
92-
}
93-
super.onPageFinished(view, url)
84+
override fun onPageFinished(
85+
view: WebView?,
86+
url: String?,
87+
) {
88+
// Clear history if mShouldHistoryBeCleared is true and set it to false
89+
if (shouldHistoryBeCleared) {
90+
binding.webView.clearHistory()
91+
shouldHistoryBeCleared = false
9492
}
93+
super.onPageFinished(view, url)
94+
}
9595

96-
/**
97-
* Prevent the WebView from loading urls which arent needed for importing shared decks.
98-
* This is to prevent potential misuse, such as bypassing content restrictions or
99-
* using the AnkiDroid WebView as a regular browser to bypass browser blocks,
100-
* which could lead to procrastination.
101-
*/
102-
override fun shouldOverrideUrlLoading(
103-
view: WebView?,
104-
request: WebResourceRequest?,
105-
): Boolean {
106-
val host = request?.url?.host
107-
if (host != null) {
108-
if (allowedHosts.any { regex -> regex.matches(host) }) {
109-
return super.shouldOverrideUrlLoading(view, request)
110-
}
96+
/**
97+
* Prevent the WebView from loading urls which arent needed for importing shared decks.
98+
* This is to prevent potential misuse, such as bypassing content restrictions or
99+
* using the AnkiDroid WebView as a regular browser to bypass browser blocks,
100+
* which could lead to procrastination.
101+
*/
102+
override fun shouldOverrideUrlLoading(
103+
view: WebView?,
104+
request: WebResourceRequest?,
105+
): Boolean {
106+
val host = request?.url?.host
107+
if (host != null) {
108+
if (allowedHosts.any { regex -> regex.matches(host) }) {
109+
return super.shouldOverrideUrlLoading(view, request)
111110
}
111+
}
112112

113-
request?.url?.let { super@SharedDecksActivity.openUrl(it) }
113+
request?.url?.let { super@SharedDecksActivity.openUrl(it) }
114114

115-
return true
116-
}
115+
return true
116+
}
117117

118-
private val cookieManager: CookieManager by lazy {
119-
CookieManager.getInstance()
120-
}
118+
private val cookieManager: CookieManager by lazy {
119+
CookieManager.getInstance()
120+
}
121121

122-
private val isLoggedInToAnkiWeb: Boolean
123-
get() {
124-
try {
125-
// cookies are null after the user logs out, or if the site is first visited
126-
val cookies = cookieManager.getCookie("https://ankiweb.net") ?: return false
127-
// ankiweb currently (2024-09-25) sets two cookies:
128-
// * `ankiweb`, which is base64-encoded JSON
129-
// * `has_auth`, which is 1
130-
return cookies.contains("has_auth=1")
131-
} catch (e: Exception) {
132-
Timber.w(e, "Could not determine login status")
133-
return false
134-
}
122+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
123+
internal val isLoggedInToAnkiWeb: Boolean
124+
get() {
125+
try {
126+
// cookies are null after the user logs out, or if the site is first visited
127+
val cookies = cookieManager.getCookie("https://ankiweb.net") ?: return false
128+
// ankiweb currently (2024-09-25) sets two cookies:
129+
// * `ankiweb`, which is base64-encoded JSON
130+
// * `has_auth`, which is 1
131+
return cookies.contains("has_auth=1")
132+
} catch (e: Exception) {
133+
Timber.w(e, "Could not determine login status")
134+
return false
135135
}
136+
}
136137

137-
@NeedsTest("A user is not redirected to login/signup if they are logged in to AnkiWeb")
138-
override fun onReceivedHttpError(
139-
view: WebView?,
140-
request: WebResourceRequest?,
141-
errorResponse: WebResourceResponse?,
142-
) {
143-
super.onReceivedHttpError(view, request, errorResponse)
138+
override fun onReceivedHttpError(
139+
view: WebView?,
140+
request: WebResourceRequest?,
141+
errorResponse: WebResourceResponse?,
142+
) {
143+
super.onReceivedHttpError(view, request, errorResponse)
144144

145-
if (errorResponse?.statusCode != HTTP_STATUS_TOO_MANY_REQUESTS) return
145+
if (errorResponse?.statusCode != HTTP_STATUS_TOO_MANY_REQUESTS) return
146146

147-
// If a user is logged in, they see: "Daily limit exceeded; please try again tomorrow."
148-
// We have nothing we can do here
149-
if (isLoggedInToAnkiWeb) return
147+
// If a user is logged in, they see: "Daily limit exceeded; please try again tomorrow."
148+
// We have nothing we can do here
149+
if (isLoggedInToAnkiWeb) return
150150

151-
// The following cases are handled below:
152-
// "Please log in to download more decks." - on clicking "Download"
153-
// "Please log in to perform more searches" - on searching
154-
redirectUserToSignUpOrLogin()
155-
}
151+
// The following cases are handled below:
152+
// "Please log in to download more decks." - on clicking "Download"
153+
// "Please log in to perform more searches" - on searching
154+
redirectUserToSignUpOrLogin()
155+
}
156156

157-
override fun onReceivedError(
158-
view: WebView?,
159-
request: WebResourceRequest?,
160-
error: WebResourceError?,
161-
) {
162-
// Set mShouldHistoryBeCleared to false if error occurs since it might have been true
163-
shouldHistoryBeCleared = false
164-
super.onReceivedError(view, request, error)
165-
}
157+
override fun onReceivedError(
158+
view: WebView?,
159+
request: WebResourceRequest?,
160+
error: WebResourceError?,
161+
) {
162+
// Set mShouldHistoryBeCleared to false if error occurs since it might have been true
163+
shouldHistoryBeCleared = false
164+
super.onReceivedError(view, request, error)
165+
}
166166

167-
/**
168-
* Redirects the user to a login page
169-
*
170-
* A message is shown informing the user they need to log in to download more decks
171-
*
172-
* If the user has not logged in **inside AnkiDroid** then the message provides
173-
* the user with an action to sign up
174-
*
175-
* The redirect is not performed if [redirectTimes] is 3 or more
176-
*/
177-
private fun redirectUserToSignUpOrLogin() {
178-
// inform the user they need to log in as they've hit a rate limit
179-
showSnackbar(R.string.shared_decks_login_required, LENGTH_INDEFINITE) {
180-
if (isLoggedIn()) return@showSnackbar
181-
182-
// If a user is not logged in inside AnkiDroid, assume they have no AnkiWeb account
183-
// and give them the option to sign up
184-
setAction(R.string.sign_up) {
185-
binding.webView.loadUrl(getString(R.string.shared_decks_sign_up_url))
186-
}
167+
/**
168+
* Redirects the user to a login page
169+
*
170+
* A message is shown informing the user they need to log in to download more decks
171+
*
172+
* If the user has not logged in **inside AnkiDroid** then the message provides
173+
* the user with an action to sign up
174+
*
175+
* The redirect is not performed if [redirectTimes] is 3 or more
176+
*/
177+
private fun redirectUserToSignUpOrLogin() {
178+
// inform the user they need to log in as they've hit a rate limit
179+
showSnackbar(R.string.shared_decks_login_required, LENGTH_INDEFINITE) {
180+
if (isLoggedIn()) return@showSnackbar
181+
182+
// If a user is not logged in inside AnkiDroid, assume they have no AnkiWeb account
183+
// and give them the option to sign up
184+
setAction(R.string.sign_up) {
185+
binding.webView.loadUrl(getString(R.string.shared_decks_sign_up_url))
187186
}
187+
}
188188

189-
// redirect user to /account/login
190-
// TODO: the result of login is typically redirecting the user to their decks
191-
// this should be improved
192-
193-
if (redirectTimes++ < 3) {
194-
val url = getString(R.string.shared_decks_login_url)
195-
Timber.i("HTTP 429, redirecting to login: '$url'")
196-
binding.webView.loadUrl(url)
197-
} else {
198-
// Ensure that we do not have an infinite redirect
199-
Timber.w("HTTP 429 redirect limit exceeded, only displaying message")
200-
}
189+
// redirect user to /account/login
190+
// TODO: the result of login is typically redirecting the user to their decks
191+
// this should be improved
192+
193+
if (redirectTimes++ < 3) {
194+
val url = getString(R.string.shared_decks_login_url)
195+
Timber.i("HTTP 429, redirecting to login: '$url'")
196+
binding.webView.loadUrl(url)
197+
} else {
198+
// Ensure that we do not have an infinite redirect
199+
Timber.w("HTTP 429 redirect limit exceeded, only displaying message")
201200
}
202201
}
202+
}
203+
204+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
205+
internal val webViewClient = SharedDeckWebViewClient()
203206

204207
companion object {
205208
const val SHARED_DECKS_DOWNLOAD_FRAGMENT = "SharedDecksDownloadFragment"
206209
const val DOWNLOAD_FILE = "DownloadFile"
207-
private const val HTTP_STATUS_TOO_MANY_REQUESTS = 429
210+
211+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
212+
const val HTTP_STATUS_TOO_MANY_REQUESTS = 429
208213
}
209214

210215
// Show WebView with AnkiWeb shared decks with the functionality to capture downloads and import decks.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// SPDX-FileCopyrightText: Copyright (c) 2026 YongWoo Shin <onlym6659@gmail.com>
3+
4+
package com.ichi2.anki
5+
6+
import android.webkit.WebResourceRequest
7+
import android.webkit.WebResourceResponse
8+
import android.webkit.WebView
9+
import androidx.test.ext.junit.runners.AndroidJUnit4
10+
import com.ichi2.anki.SharedDecksActivity.Companion.HTTP_STATUS_TOO_MANY_REQUESTS
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
import org.mockito.Mockito.mock
14+
import org.mockito.Mockito.spy
15+
import org.mockito.kotlin.doReturn
16+
import org.mockito.kotlin.whenever
17+
import org.robolectric.Robolectric
18+
import org.robolectric.Shadows
19+
import kotlin.test.assertEquals
20+
21+
@RunWith(AndroidJUnit4::class)
22+
class SharedDecksActivityTest : RobolectricTest() {
23+
@Test
24+
fun redirectWhenUserIsNotLogin() {
25+
val controller = Robolectric.buildActivity(SharedDecksActivity::class.java).create()
26+
val activity = controller.get()
27+
saveControllerForCleanup(controller)
28+
29+
val webView = activity.findViewById<WebView>(R.id.web_view)
30+
val spyWebClient = spy(activity.webViewClient)
31+
doReturn(false).whenever(spyWebClient).isLoggedInToAnkiWeb
32+
webView.webViewClient = spyWebClient
33+
34+
controller.start().resume().visible()
35+
36+
val mockRequest = mock<WebResourceRequest>()
37+
val mockErrorResponse = mock<WebResourceResponse>()
38+
whenever(mockErrorResponse.statusCode).thenReturn(HTTP_STATUS_TOO_MANY_REQUESTS)
39+
40+
spyWebClient.onReceivedHttpError(webView, mockRequest, mockErrorResponse)
41+
42+
val expectedUrl = getResourceString(R.string.shared_decks_login_url)
43+
val actualUrl = Shadows.shadowOf(webView).lastLoadedUrl
44+
assertEquals(expectedUrl, actualUrl)
45+
}
46+
47+
@Test
48+
fun isNotRedirectWhenUserLogin() {
49+
val controller = Robolectric.buildActivity(SharedDecksActivity::class.java).create()
50+
val activity = controller.get()
51+
saveControllerForCleanup(controller)
52+
53+
val webView = activity.findViewById<WebView>(R.id.web_view)
54+
val spyWebClient = spy(activity.webViewClient)
55+
doReturn(true).whenever(spyWebClient).isLoggedInToAnkiWeb
56+
webView.webViewClient = spyWebClient
57+
58+
controller.start().resume().visible()
59+
60+
val mockRequest = mock<WebResourceRequest>()
61+
val mockErrorResponse = mock<WebResourceResponse>()
62+
whenever(mockErrorResponse.statusCode).thenReturn(HTTP_STATUS_TOO_MANY_REQUESTS)
63+
64+
spyWebClient.onReceivedHttpError(webView, mockRequest, mockErrorResponse)
65+
66+
val expectedUrl = getResourceString(R.string.shared_decks_url)
67+
val lastUrl = Shadows.shadowOf(webView).lastLoadedUrl
68+
assertEquals(expectedUrl, lastUrl)
69+
}
70+
}

0 commit comments

Comments
 (0)