Add preparing navigation to roadmap
This commit is contained in:
@@ -13,20 +13,25 @@ import dev.ulfrx.recipe.ui.screens.auth.PostLoginPlaceholderScreen
|
||||
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.auth.SplashScreen
|
||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||
import dev.ulfrx.recipe.user.UserRepository
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
/**
|
||||
* Auth gate. Observes [AuthSession.state] and renders Splash / Login / PostLogin per
|
||||
* `02-UI-SPEC.md` § Auth Gate Routing Contract. State changes drive recomposition;
|
||||
* no manual navigation. Phase 3 replaces the `Authenticated` branch with `HouseholdGate`.
|
||||
* Two-layer gate: [AuthSession] tells us whether tokens exist; [UserRepository]
|
||||
* tells us who the authenticated principal is in the app's data model. While
|
||||
* tokens are present but the `/me` fetch hasn't returned yet, we hold the
|
||||
* splash so the user never sees an empty post-login screen. Phase 3 replaces
|
||||
* the `Authenticated + user` branch with `HouseholdGate`.
|
||||
*/
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
RecipeTheme {
|
||||
val authSession = koinInject<AuthSession>()
|
||||
val userRepository = koinInject<UserRepository>()
|
||||
val authState by authSession.state.collectAsStateWithLifecycle()
|
||||
val currentUser by userRepository.currentUser.collectAsStateWithLifecycle()
|
||||
|
||||
// Kick off the persisted-session restore once. AuthSession.initialize()
|
||||
// refreshes the stored AuthState (or transitions to Unauthenticated on
|
||||
@@ -35,20 +40,21 @@ fun App() {
|
||||
authSession.initialize()
|
||||
}
|
||||
|
||||
when (val current = authState) {
|
||||
AuthState.Loading -> {
|
||||
SplashScreen()
|
||||
}
|
||||
when (authState) {
|
||||
AuthState.Loading -> SplashScreen()
|
||||
|
||||
AuthState.Unauthenticated -> {
|
||||
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
|
||||
}
|
||||
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
|
||||
|
||||
is AuthState.Authenticated -> {
|
||||
PostLoginPlaceholderScreen(
|
||||
user = current.user,
|
||||
viewModel = koinViewModel<PostLoginViewModel>(),
|
||||
)
|
||||
AuthState.Authenticated -> {
|
||||
val user = currentUser
|
||||
if (user == null) {
|
||||
SplashScreen()
|
||||
} else {
|
||||
PostLoginPlaceholderScreen(
|
||||
user = user,
|
||||
viewModel = koinViewModel<PostLoginViewModel>(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,18 @@ import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
|
||||
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
|
||||
import io.ktor.client.HttpClient
|
||||
import org.koin.dsl.module
|
||||
import org.koin.plugin.module.dsl.single
|
||||
import org.koin.plugin.module.dsl.viewModel
|
||||
|
||||
val authModule =
|
||||
module {
|
||||
single<SecureAuthStateStore>()
|
||||
single<OidcClient>()
|
||||
single<MeClient>()
|
||||
single<AuthSession>()
|
||||
single<SecureAuthStateStore> { SecureAuthStateStore(get()) }
|
||||
single<OidcClient> { OidcClient(get()) }
|
||||
single<AuthSession> {
|
||||
AuthSession(
|
||||
oidcClient = get<OidcClient>(),
|
||||
store = get<SecureAuthStateStore>(),
|
||||
)
|
||||
}
|
||||
single<HttpClient> { AuthHttpClient.create(get()) }
|
||||
|
||||
// Phase 2 auth-gate ViewModels (02-07). Registered here so the same module that
|
||||
|
||||
@@ -33,15 +33,18 @@ sealed interface AuthLoginResult {
|
||||
) : AuthLoginResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns *just* the authentication state machine: tokens, refresh, logout.
|
||||
* User profile fetch lives in [dev.ulfrx.recipe.user.UserRepository], which
|
||||
* observes [state] and reacts to transitions.
|
||||
*/
|
||||
class AuthSession(
|
||||
private val oidcClient: OidcClientGateway,
|
||||
private val store: AuthStateStore,
|
||||
private val meClient: MeGateway,
|
||||
) {
|
||||
constructor(
|
||||
oidcClient: OidcClient,
|
||||
store: SecureAuthStateStore,
|
||||
meClient: MeClient,
|
||||
) : this(
|
||||
oidcClient =
|
||||
object : OidcClientGateway {
|
||||
@@ -65,7 +68,6 @@ class AuthSession(
|
||||
store.clear()
|
||||
}
|
||||
},
|
||||
meClient = meClient,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
|
||||
@@ -83,7 +85,7 @@ class AuthSession(
|
||||
}
|
||||
|
||||
when (val refreshResult = oidcClient.refresh(storedJson)) {
|
||||
is OidcResult.Success -> authenticate(refreshResult)
|
||||
is OidcResult.Success -> persistAndAuthenticate(refreshResult)
|
||||
|
||||
OidcResult.Cancelled,
|
||||
OidcResult.NetworkError,
|
||||
@@ -95,7 +97,7 @@ class AuthSession(
|
||||
suspend fun login(browser: AuthBrowser): AuthLoginResult =
|
||||
when (val loginResult = oidcClient.login(browser)) {
|
||||
is OidcResult.Success -> {
|
||||
authenticate(loginResult)
|
||||
persistAndAuthenticate(loginResult)
|
||||
AuthLoginResult.Success
|
||||
}
|
||||
|
||||
@@ -153,10 +155,9 @@ class AuthSession(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun authenticate(result: OidcResult.Success) {
|
||||
private fun persistAndAuthenticate(result: OidcResult.Success) {
|
||||
persistTokens(result)
|
||||
val user = meClient.getMe(result.accessToken)
|
||||
_state.value = AuthState.Authenticated(user = user, householdId = null)
|
||||
_state.value = AuthState.Authenticated
|
||||
}
|
||||
|
||||
private fun persistTokens(result: OidcResult.Success) {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import dev.ulfrx.recipe.shared.dto.User
|
||||
|
||||
typealias HouseholdId = String
|
||||
|
||||
/**
|
||||
* Pure authentication state — token-bearing or not. User profile (display name,
|
||||
* email, server-issued id, household membership) lives behind
|
||||
* [dev.ulfrx.recipe.user.UserRepository] and is loaded after auth flips to
|
||||
* [Authenticated]. Screens that need a user observe both flows.
|
||||
*/
|
||||
sealed class AuthState {
|
||||
data object Loading : AuthState()
|
||||
|
||||
data object Unauthenticated : AuthState()
|
||||
|
||||
data class Authenticated(
|
||||
val user: User,
|
||||
val householdId: HouseholdId? = null,
|
||||
) : AuthState()
|
||||
data object Authenticated : AuthState()
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import dev.ulfrx.recipe.shared.Constants
|
||||
import dev.ulfrx.recipe.shared.dto.User
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
interface MeGateway {
|
||||
suspend fun getMe(accessToken: String? = null): User
|
||||
}
|
||||
|
||||
class MeClient(
|
||||
private val httpClient: HttpClient =
|
||||
HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(authJson)
|
||||
}
|
||||
},
|
||||
) : MeGateway {
|
||||
override suspend fun getMe(accessToken: String?): User =
|
||||
httpClient
|
||||
.get("${Constants.API_BASE_URL}api/v1/me") {
|
||||
if (!accessToken.isNullOrBlank()) {
|
||||
header(HttpHeaders.Authorization, "Bearer ".plus(accessToken))
|
||||
}
|
||||
}.body<dev.ulfrx.recipe.shared.dto.MeResponse>()
|
||||
.toUser()
|
||||
|
||||
private companion object {
|
||||
val authJson =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package dev.ulfrx.recipe.di
|
||||
|
||||
import dev.ulfrx.recipe.auth.authModule
|
||||
import dev.ulfrx.recipe.user.userModule
|
||||
import org.koin.dsl.module
|
||||
|
||||
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
|
||||
// Phase 2 adds authModule + userModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
|
||||
val appModule =
|
||||
module {
|
||||
includes(authModule)
|
||||
includes(authModule, userModule)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.ulfrx.recipe.user
|
||||
|
||||
import dev.ulfrx.recipe.shared.Constants
|
||||
import dev.ulfrx.recipe.shared.dto.MeResponse
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import org.koin.dsl.module
|
||||
|
||||
val userModule =
|
||||
module {
|
||||
single<UserRepository> {
|
||||
UserRepository(
|
||||
authSession = get(),
|
||||
fetchUser = {
|
||||
get<HttpClient>()
|
||||
.get("${Constants.API_BASE_URL}api/v1/me")
|
||||
.body<MeResponse>()
|
||||
.toUser()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dev.ulfrx.recipe.user
|
||||
|
||||
import dev.ulfrx.recipe.auth.AuthSession
|
||||
import dev.ulfrx.recipe.auth.AuthState
|
||||
import dev.ulfrx.recipe.shared.dto.User
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Owns the current authenticated user as observable state.
|
||||
*
|
||||
* Subscribes to [AuthSession.state] for life: when auth flips to
|
||||
* [AuthState.Authenticated] it fetches `/me` via [fetchUser] once and emits
|
||||
* the result through [currentUser]. On [AuthState.Unauthenticated] it clears
|
||||
* [currentUser] so screens drop back to the login gate cleanly.
|
||||
*
|
||||
* The fetch is a `suspend () -> User` lambda rather than a wrapped gateway:
|
||||
* one consumer, one impl, no interface needed. When Phase 4 introduces a
|
||||
* SyncEngine with local + remote sources, extract the seam then.
|
||||
*/
|
||||
class UserRepository(
|
||||
private val authSession: AuthSession,
|
||||
private val fetchUser: suspend () -> User,
|
||||
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
) {
|
||||
private val _currentUser = MutableStateFlow<User?>(null)
|
||||
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authSession.state
|
||||
.collect { state ->
|
||||
when (state) {
|
||||
is AuthState.Authenticated -> {
|
||||
if (_currentUser.value == null) {
|
||||
runCatching { fetchUser() }
|
||||
.onSuccess { user -> _currentUser.value = user }
|
||||
}
|
||||
}
|
||||
|
||||
AuthState.Unauthenticated -> _currentUser.value = null
|
||||
AuthState.Loading -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refresh() {
|
||||
runCatching { fetchUser() }
|
||||
.onSuccess { user -> _currentUser.value = user }
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package dev.ulfrx.recipe.auth
|
||||
|
||||
import dev.lokksmith.client.request.flow.AuthFlow
|
||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||
import dev.ulfrx.recipe.shared.dto.User
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -24,7 +23,7 @@ class AuthSessionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() {
|
||||
fun successfulLoginPersistsAuthStateAndEmitsAuthenticated() {
|
||||
runTest {
|
||||
val store = FakeAuthStateStore()
|
||||
val oidcClient =
|
||||
@@ -37,22 +36,18 @@ class AuthSessionTest {
|
||||
expiresAtEpochMillis = 123_456L,
|
||||
),
|
||||
)
|
||||
val meClient = FakeMeClient(user = USER)
|
||||
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
val result = session.login(NoopBrowser)
|
||||
|
||||
assertEquals(AuthLoginResult.Success, result)
|
||||
assertEquals(AUTH_STATE_JSON, store.value)
|
||||
assertEquals(listOf<String?>(ACCESS_TOKEN), meClient.accessTokens)
|
||||
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
|
||||
assertEquals(USER, authenticated.user)
|
||||
assertNull(authenticated.householdId)
|
||||
assertIs<AuthState.Authenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() {
|
||||
fun existingStoreRefreshesAndEmitsAuthenticatedWithoutLogin() {
|
||||
runTest {
|
||||
val store = FakeAuthStateStore(value = "stored-auth-state-json")
|
||||
val oidcClient =
|
||||
@@ -65,18 +60,14 @@ class AuthSessionTest {
|
||||
expiresAtEpochMillis = 789_000L,
|
||||
),
|
||||
)
|
||||
val meClient = FakeMeClient(user = USER)
|
||||
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
|
||||
val session = newSession(store = store, oidcClient = oidcClient)
|
||||
|
||||
session.initialize()
|
||||
|
||||
assertEquals(emptyList(), oidcClient.loginCalls)
|
||||
assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls)
|
||||
assertEquals(REFRESHED_AUTH_STATE_JSON, store.value)
|
||||
assertEquals(listOf<String?>(REFRESHED_ACCESS_TOKEN), meClient.accessTokens)
|
||||
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
|
||||
assertEquals(USER, authenticated.user)
|
||||
assertNull(authenticated.householdId)
|
||||
assertIs<AuthState.Authenticated>(session.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,12 +156,10 @@ class AuthSessionTest {
|
||||
private fun newSession(
|
||||
store: AuthStateStore = FakeAuthStateStore(),
|
||||
oidcClient: OidcClientGateway = FakeOidcClient(),
|
||||
meClient: MeGateway = FakeMeClient(user = USER),
|
||||
): AuthSession =
|
||||
AuthSession(
|
||||
oidcClient = oidcClient,
|
||||
store = store,
|
||||
meClient = meClient,
|
||||
)
|
||||
|
||||
private object NoopBrowser : AuthBrowser {
|
||||
@@ -219,29 +208,10 @@ class AuthSessionTest {
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeMeClient(
|
||||
private val user: User,
|
||||
) : MeGateway {
|
||||
val accessTokens = mutableListOf<String?>()
|
||||
|
||||
override suspend fun getMe(accessToken: String?): User {
|
||||
accessTokens += accessToken
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val AUTH_STATE_JSON = """{"refresh_token":"initial"}"""
|
||||
const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}"""
|
||||
const val ACCESS_TOKEN = "access-token"
|
||||
const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token"
|
||||
|
||||
val USER =
|
||||
User(
|
||||
id = "00000000-0000-0000-0000-000000000001",
|
||||
sub = "authentik-sub",
|
||||
email = "user@example.invalid",
|
||||
displayName = "Recipe User",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@ import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||
import dev.ulfrx.recipe.auth.AuthBrowser
|
||||
import dev.ulfrx.recipe.auth.AuthSession
|
||||
import dev.ulfrx.recipe.auth.AuthStateStore
|
||||
import dev.ulfrx.recipe.auth.MeGateway
|
||||
import dev.ulfrx.recipe.auth.OidcClientGateway
|
||||
import dev.ulfrx.recipe.auth.OidcResult
|
||||
import dev.ulfrx.recipe.shared.dto.User
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
@@ -95,7 +93,7 @@ class LoginViewModelTest {
|
||||
|
||||
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
|
||||
}
|
||||
val session = AuthSession(oidc, FakeAuthStateStore(), FakeMeClient(USER))
|
||||
val session = AuthSession(oidc, FakeAuthStateStore())
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
// First attempt: error seeded.
|
||||
@@ -120,12 +118,10 @@ class LoginViewModelTest {
|
||||
private fun newSession(
|
||||
loginResult: OidcResult,
|
||||
store: AuthStateStore = FakeAuthStateStore(),
|
||||
meClient: MeGateway = FakeMeClient(USER),
|
||||
): AuthSession =
|
||||
AuthSession(
|
||||
oidcClient = FakeOidcClient(loginResult = loginResult),
|
||||
store = store,
|
||||
meClient = meClient,
|
||||
)
|
||||
|
||||
private object NoopBrowser : AuthBrowser {
|
||||
@@ -157,20 +153,4 @@ class LoginViewModelTest {
|
||||
|
||||
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
|
||||
}
|
||||
|
||||
private class FakeMeClient(
|
||||
private val user: User,
|
||||
) : MeGateway {
|
||||
override suspend fun getMe(accessToken: String?): User = user
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val USER =
|
||||
User(
|
||||
id = "00000000-0000-0000-0000-000000000001",
|
||||
sub = "authentik-sub",
|
||||
email = "user@example.invalid",
|
||||
displayName = "Recipe User",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package dev.ulfrx.recipe.user
|
||||
|
||||
import dev.lokksmith.client.request.flow.AuthFlow
|
||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||
import dev.ulfrx.recipe.auth.AuthBrowser
|
||||
import dev.ulfrx.recipe.auth.AuthSession
|
||||
import dev.ulfrx.recipe.auth.AuthStateStore
|
||||
import dev.ulfrx.recipe.auth.OidcClientGateway
|
||||
import dev.ulfrx.recipe.auth.OidcResult
|
||||
import dev.ulfrx.recipe.shared.dto.User
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class UserRepositoryTest {
|
||||
@Test
|
||||
fun fetchesUserWhenAuthFlipsToAuthenticated() =
|
||||
runTest {
|
||||
val session = newSession()
|
||||
var fetchCount = 0
|
||||
val repository =
|
||||
UserRepository(
|
||||
authSession = session,
|
||||
fetchUser = { fetchCount++; USER },
|
||||
scope = TestScope(testScheduler),
|
||||
)
|
||||
|
||||
session.login(NoopBrowser)
|
||||
|
||||
val user = repository.currentUser.first { it != null }
|
||||
assertEquals(USER, user)
|
||||
assertEquals(1, fetchCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearsUserOnLogout() =
|
||||
runTest {
|
||||
val session = newSession()
|
||||
val repository =
|
||||
UserRepository(
|
||||
authSession = session,
|
||||
fetchUser = { USER },
|
||||
scope = TestScope(testScheduler),
|
||||
)
|
||||
|
||||
session.login(NoopBrowser)
|
||||
repository.currentUser.first { it != null }
|
||||
|
||||
session.logout(NoopBrowser)
|
||||
|
||||
val cleared = repository.currentUser.firstOrNull { it == null }
|
||||
assertNull(cleared)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun networkFailureLeavesCurrentUserNullWithoutCrashing() =
|
||||
runTest {
|
||||
val session = newSession()
|
||||
val repository =
|
||||
UserRepository(
|
||||
authSession = session,
|
||||
fetchUser = { error("network down") },
|
||||
scope = TestScope(testScheduler),
|
||||
)
|
||||
|
||||
session.login(NoopBrowser)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertNull(repository.currentUser.value)
|
||||
}
|
||||
|
||||
private fun newSession(): AuthSession =
|
||||
AuthSession(
|
||||
oidcClient = FakeOidcClient(loginResult = SUCCESS),
|
||||
store = FakeAuthStateStore(),
|
||||
)
|
||||
|
||||
private object NoopBrowser : AuthBrowser {
|
||||
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
|
||||
AuthFlowResultProvider.Result.Undefined
|
||||
}
|
||||
|
||||
private class FakeAuthStateStore(
|
||||
var value: String? = null,
|
||||
) : AuthStateStore {
|
||||
override fun read(): String? = value
|
||||
|
||||
override fun write(authStateJson: String) {
|
||||
value = authStateJson
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
value = null
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeOidcClient(
|
||||
private val loginResult: OidcResult,
|
||||
) : OidcClientGateway {
|
||||
override suspend fun login(browser: AuthBrowser): OidcResult = loginResult
|
||||
|
||||
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
|
||||
|
||||
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SUCCESS =
|
||||
OidcResult.Success(
|
||||
authStateJson = "{}",
|
||||
accessToken = "access",
|
||||
idToken = null,
|
||||
expiresAtEpochMillis = 0L,
|
||||
)
|
||||
|
||||
val USER =
|
||||
User(
|
||||
id = "00000000-0000-0000-0000-000000000001",
|
||||
sub = "authentik-sub",
|
||||
email = "user@example.invalid",
|
||||
displayName = "Recipe User",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user