Create API token when signing in

This commit is contained in:
Kirill Kamakin
2022-12-11 11:57:13 +01:00
parent a560db8bb6
commit c99f9fea88
21 changed files with 130 additions and 124 deletions

View File

@@ -5,4 +5,6 @@ interface AuthDataSource {
* Tries to acquire authentication token using the provided credentials
*/
suspend fun authenticate(username: String, password: String): String
suspend fun createApiToken(name: String): String
}

View File

@@ -10,9 +10,5 @@ interface AuthRepo {
suspend fun getAuthHeader(): String?
suspend fun requireAuthHeader(): String
suspend fun logout()
suspend fun invalidateAuthHeader()
}

View File

@@ -9,12 +9,4 @@ interface AuthStorage {
suspend fun setAuthHeader(authHeader: String?)
suspend fun getAuthHeader(): String?
suspend fun setEmail(email: String?)
suspend fun getEmail(): String?
suspend fun setPassword(password: String?)
suspend fun getPassword(): String?
}

View File

@@ -4,7 +4,9 @@ import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1
import javax.inject.Inject
import javax.inject.Singleton
@@ -15,14 +17,20 @@ class AuthDataSourceImpl @Inject constructor(
private val v1Source: MealieDataSourceV1,
) : AuthDataSource {
private suspend fun getVersion(): ServerVersion = serverInfoRepo.getVersion()
private suspend fun getUrl(): String = serverInfoRepo.requireUrl()
override suspend fun authenticate(
username: String,
password: String,
): String {
val baseUrl = serverInfoRepo.requireUrl()
return when (serverInfoRepo.getVersion()) {
ServerVersion.V0 -> v0Source.authenticate(baseUrl, username, password)
ServerVersion.V1 -> v1Source.authenticate(baseUrl, username, password)
}
): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.authenticate(getUrl(), username, password)
ServerVersion.V1 -> v1Source.authenticate(getUrl(), username, password)
}
override suspend fun createApiToken(name: String): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.createApiToken(getUrl(), CreateApiTokenRequestV0(name))
ServerVersion.V1 -> v1Source.createApiToken(getUrl(), CreateApiTokenRequestV1(name)).token
}
}

View File

@@ -4,7 +4,6 @@ import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -24,34 +23,20 @@ class AuthRepoImpl @Inject constructor(
override suspend fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" }
val token = authDataSource.authenticate(email, password)
val header = AUTH_HEADER_FORMAT.format(token)
authStorage.setAuthHeader(header)
authStorage.setEmail(email)
authStorage.setPassword(password)
authStorage.setAuthHeader(AUTH_HEADER_FORMAT.format(token))
val apiToken = authDataSource.createApiToken(API_TOKEN_NAME)
authStorage.setAuthHeader(AUTH_HEADER_FORMAT.format(apiToken))
}
override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader()
override suspend fun requireAuthHeader(): String = checkNotNull(getAuthHeader()) {
"Auth header is null when it was required"
}
override suspend fun logout() {
logger.v { "logout() called" }
authStorage.setEmail(null)
authStorage.setPassword(null)
authStorage.setAuthHeader(null)
}
override suspend fun invalidateAuthHeader() {
logger.v { "invalidateAuthHeader() called" }
val email = authStorage.getEmail() ?: return
val password = authStorage.getPassword() ?: return
runCatchingExceptCancel { authenticate(email, password) }
.onFailure { logout() } // Clear all known values to avoid reusing them
}
companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s"
private const val API_TOKEN_NAME = "Mealient"
}
}

View File

@@ -32,14 +32,6 @@ class AuthStorageImpl @Inject constructor(
override suspend fun getAuthHeader(): String? = getString(AUTH_HEADER_KEY)
override suspend fun setEmail(email: String?) = putString(EMAIL_KEY, email)
override suspend fun getEmail(): String? = getString(EMAIL_KEY)
override suspend fun setPassword(password: String?) = putString(PASSWORD_KEY, password)
override suspend fun getPassword(): String? = getString(PASSWORD_KEY)
private suspend fun putString(
key: String,
value: String?
@@ -57,11 +49,5 @@ class AuthStorageImpl @Inject constructor(
companion object {
@VisibleForTesting
const val AUTH_HEADER_KEY = "authHeader"
@VisibleForTesting
const val EMAIL_KEY = "email"
@VisibleForTesting
const val PASSWORD_KEY = "password"
}
}

View File

@@ -6,6 +6,8 @@ import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_API_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_API_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
@@ -51,13 +53,15 @@ class AuthRepoImplTest : BaseUnitTest() {
@Test
fun `when authenticate successfully then saves to storage`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN
coEvery { dataSource.authenticate(any(), any()) } returns TEST_TOKEN
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { dataSource.createApiToken(any()) } returns TEST_API_TOKEN
subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
coVerifyAll {
coVerify {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD))
storage.setAuthHeader(TEST_AUTH_HEADER)
storage.setEmail(TEST_USERNAME)
storage.setPassword(TEST_PASSWORD)
dataSource.createApiToken(eq("Mealient"))
storage.setAuthHeader(TEST_API_AUTH_HEADER)
}
confirmVerified(storage)
}
@@ -71,50 +75,9 @@ class AuthRepoImplTest : BaseUnitTest() {
}
@Test
fun `when logout then removes email, password and header`() = runTest {
fun `when logout expect header removal`() = runTest {
subject.logout()
coVerifyAll {
storage.setEmail(null)
storage.setPassword(null)
storage.setAuthHeader(null)
}
coVerify { storage.setAuthHeader(null) }
confirmVerified(storage)
}
@Test
fun `when invalidate then does not authenticate without email`() = runTest {
coEvery { storage.getEmail() } returns null
coEvery { storage.getPassword() } returns TEST_PASSWORD
subject.invalidateAuthHeader()
confirmVerified(dataSource)
}
@Test
fun `when invalidate then does not authenticate without password`() = runTest {
coEvery { storage.getEmail() } returns TEST_USERNAME
coEvery { storage.getPassword() } returns null
subject.invalidateAuthHeader()
confirmVerified(dataSource)
}
@Test
fun `when invalidate with credentials then calls authenticate`() = runTest {
coEvery { storage.getEmail() } returns TEST_USERNAME
coEvery { storage.getPassword() } returns TEST_PASSWORD
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN
subject.invalidateAuthHeader()
coVerifyAll { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) }
}
@Test
fun `when invalidate with credentials and auth fails then clears email`() = runTest {
coEvery { storage.getEmail() } returns "invalid"
coEvery { storage.getPassword() } returns ""
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { dataSource.authenticate(any(), any()) } throws RuntimeException()
subject.invalidateAuthHeader()
coVerify { storage.setEmail(null) }
}
}

View File

@@ -8,12 +8,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_HEADER_KEY
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.EMAIL_KEY
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.PASSWORD_KEY
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.FakeLogger
import gq.kirmanak.mealient.test.HiltRobolectricTest
import io.mockk.MockKAnnotations
@@ -55,16 +51,4 @@ class AuthStorageImplTest : HiltRobolectricTest() {
fun `when authHeader is observed then sends null if nothing saved`() = runTest {
assertThat(subject.authHeaderFlow.first()).isEqualTo(null)
}
@Test
fun `when setEmail then edits shared preferences`() = runTest {
subject.setEmail(TEST_USERNAME)
assertThat(sharedPreferences.getString(EMAIL_KEY, null)).isEqualTo(TEST_USERNAME)
}
@Test
fun `when getPassword then reads shared preferences`() = runTest {
sharedPreferences.edit(commit = true) { putString(PASSWORD_KEY, TEST_PASSWORD) }
assertThat(subject.getPassword()).isEqualTo(TEST_PASSWORD)
}
}

View File

@@ -8,6 +8,8 @@ object AuthImplTestData {
const val TEST_BASE_URL = "https://example.com/"
const val TEST_TOKEN = "TEST_TOKEN"
const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN"
const val TEST_API_TOKEN = "TEST_API_TOKEN"
const val TEST_API_AUTH_HEADER = "Bearer TEST_API_TOKEN"
const val TEST_VERSION = "v0.5.6"
val TEST_SERVER_VERSION_V0 = ServerVersion.V0
val TEST_SERVER_VERSION_V1 = ServerVersion.V1