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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ dependencies {
androidTestImplementation(testFixtures(projects.feature.mindrecord.domain))
androidTestImplementation(projects.feature.receiver.domain)
androidTestImplementation(testFixtures(projects.feature.receiver.domain))
androidTestImplementation(testFixtures(projects.feature.setting.data))
androidTestImplementation(projects.feature.setting.data)
androidTestImplementation(projects.feature.setting.domain)
androidTestImplementation(testFixtures(projects.feature.setting.domain))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package com.afternote.afternote_fe

import android.os.Build
import androidx.activity.compose.setContent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.afternote.afternote_fe.navigation.AppNavigation
import com.afternote.afternote_fe.navigation.rememberAfternoteAppState
import com.afternote.core.ui.Route
import com.afternote.core.ui.theme.AfternoteTheme
import com.afternote.feature.setting.data.PasskeyTestScenario
import com.afternote.feature.setting.domain.Passkey
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import jakarta.inject.Inject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeFalse
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class PasskeyRegistrationAndroidTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()

@Inject
lateinit var scenario: PasskeyTestScenario

@Before
fun setUp() {
hiltRule.inject()
composeRule.activityRule.scenario.onActivity { activity ->
activity.setContent {
AfternoteTheme {
AppNavigation(startDestination = Route.Setting(), appState = rememberAfternoteAppState())
}
}
}
}

@Test
fun passkeyList_queriesServerWithoutLocalFlagAndRefreshesOnReturn() {
scenario.passkeys = listOf(Passkey(7L, "Server passkey", "2026-09-06T10:00:00"))
openPasskeys()
waitForText("Server passkey")
composeRule.onNodeWithText("Server passkey").assertIsDisplayed()

composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() }
scenario.passkeys = listOf(Passkey(8L, "New server passkey", "2026-09-06T11:00:00"))
openPasskeys()
waitForText("New server passkey")
composeRule.onNodeWithText("Server passkey").assertDoesNotExist()
assertTrue(scenario.listCalls.get() >= 2)
}

@Test
fun passkeyList_failureShowsRetryAndNeverPretendsThereAreNoPasskeys() {
scenario.listFails = true
openPasskeys()
waitForText("패스키 목록을 불러올 수 없습니다.")
composeRule.onNodeWithText("패스키 등록").assertDoesNotExist()

scenario.listFails = false
composeRule.onNodeWithText("다시 시도").performClick()
waitForText("패스키 등록")
composeRule.onNodeWithText("패스키 등록").assertIsDisplayed()
assertEquals(2, scenario.listCalls.get())
}

@Test
fun passkeyRegistration_withoutCredentialProviderShowsFailureAndDoesNotComplete() {
// CI의 AOSP lane에서 실제 Credential Manager 실패를 검증한다. 계정이 있는 Play 기기는 대상이 아니다.
val packageManager = composeRule.activity.packageManager
val hasPlayServices =
runCatching { packageManager.getPackageInfo("com.google.android.gms", 0) }.isSuccess
assumeFalse(hasPlayServices)
assumeFalse(Build.VERSION.SDK_INT < 28)

openPasskeyPassword()
listOf("1", "2", "3", "4").forEach { composeRule.onNodeWithText(it).performClick() }
waitForText("패스키 등록에 실패했습니다. 다시 시도해 주세요.")
composeRule.onNodeWithText("패스키 등록에 실패했습니다. 다시 시도해 주세요.").assertIsDisplayed()
assertEquals(1, scenario.optionsCalls.get())
assertTrue(scenario.registeredCredentials.isEmpty())
composeRule.onNodeWithText("패스키 생성이 완료되었습니다").assertDoesNotExist()
}

@Test
fun passkeyList_existingEntryRefreshesAfterRegistrationScreenReturns() {
scenario.passkeys = emptyList()
openPasskeys()
waitForText("패스키 등록")
composeRule.onNodeWithText("패스키 등록").performClick()
waitForText("비밀번호로 인증하기")
scenario.passkeys = listOf(Passkey(9L, "Registered on return", "2026-09-08T10:00:00"))
composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() }
waitForText("Registered on return")
assertEquals(2, scenario.listCalls.get())
}

private fun openPasskeys() {
waitForText("패스키 관리")
composeRule.onNodeWithText("패스키 관리").performScrollTo().performClick()
}

private fun openPasskeyPassword() {
openPasskeys()
waitForText("패스키 등록")
composeRule.onNodeWithText("패스키 등록").performClick()
waitForText("비밀번호로 인증하기")
composeRule.onNodeWithText("비밀번호로 인증하기").performClick()
waitForText("비밀번호를 입력해 주세요.")
}

private fun waitForText(text: String) {
composeRule.waitUntil(timeoutMillis = 15_000) {
composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty()
}
}
}
7 changes: 7 additions & 0 deletions feature/setting/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@ plugins {

android {
namespace = "com.afternote.feature.setting.data"
testFixtures {
enable = true
}
}

dependencies {
implementation(projects.feature.setting.domain)
// 탈퇴 실패 진단(ErrorReporter). core:domain·model·network 는 data 규약이 이미 붙인다.
implementation(projects.core.common)

testFixturesImplementation(projects.feature.setting.domain)
testFixturesImplementation(libs.hilt.android.testing)
add("kspTestFixtures", libs.hilt.compiler)

testImplementation(testFixtures(projects.core.domain))
testImplementation(libs.coroutines.test)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.afternote.feature.setting.data

import com.afternote.core.network.model.BaseResponse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST

internal interface PasskeyApiService {
@GET("users/passkeys")
suspend fun getPasskeys(): BaseResponse<List<PasskeyDto>>

@POST("auth/passkey/register/options")
suspend fun getRegistrationOptions(): BaseResponse<JsonElement>

@POST("auth/passkey/register")
suspend fun registerPasskey(
@Body credential: JsonElement,
): BaseResponse<PasskeyDto>
}

@Serializable
internal data class PasskeyDto(
@SerialName("id") val id: Long,
@SerialName("displayName") val displayName: String,
@SerialName("createdAt") val createdAt: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.afternote.feature.setting.data

import com.afternote.feature.setting.domain.PasskeyRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import retrofit2.create

@Module
@InstallIn(SingletonComponent::class)
internal object PasskeyModule {
@Provides
fun providePasskeyRepository(retrofit: Retrofit): PasskeyRepository = PasskeyRepositoryImpl(retrofit.create<PasskeyApiService>())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.afternote.feature.setting.data

import com.afternote.core.network.model.requireData
import com.afternote.feature.setting.domain.Passkey
import com.afternote.feature.setting.domain.PasskeyRepository
import kotlinx.serialization.json.Json

internal class PasskeyRepositoryImpl(
private val apiService: PasskeyApiService,
) : PasskeyRepository {
override suspend fun getPasskeys(): List<Passkey> = apiService.getPasskeys().requireData().map { it.toDomain() }

override suspend fun getRegistrationOptions(): String = apiService.getRegistrationOptions().requireData().toString()

override suspend fun registerPasskey(credentialJson: String): Passkey =
apiService.registerPasskey(Json.parseToJsonElement(credentialJson)).requireData().toDomain()
}

private fun PasskeyDto.toDomain(): Passkey = Passkey(id = id, displayName = displayName, createdAt = createdAt)
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.afternote.feature.setting.data

import com.afternote.core.network.model.BaseResponse
import com.afternote.feature.setting.domain.Passkey
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class PasskeyRepositoryTest {
@Test
fun registration_roundTripsCompleteServerOptionsAndPlatformCredential() =
runBlocking {
val options = Json.parseToJsonElement("""{"challenge":"challenge","rp":{"id":"example.test"},"extension":{"future":true}}""")
val credential = Json.parseToJsonElement("""{"id":"credential-id","response":{"attestationObject":"attestation"}}""")
var submitted: JsonElement? = null
val service =
object : PasskeyApiService {
override suspend fun getPasskeys(): BaseResponse<List<PasskeyDto>> = error("Not used")

override suspend fun getRegistrationOptions(): BaseResponse<JsonElement> =
BaseResponse(status = 200, code = 200, data = options)

override suspend fun registerPasskey(credential: JsonElement): BaseResponse<PasskeyDto> {
submitted = credential
return BaseResponse(status = 200, code = 200, data = DTO)
}
}
val repository = PasskeyRepositoryImpl(service)
assertEquals(options, Json.parseToJsonElement(repository.getRegistrationOptions()))
assertEquals(Passkey(DTO.id, DTO.displayName, DTO.createdAt), repository.registerPasskey(credential.toString()))
assertEquals(credential, submitted)
}

@Test
fun list_preservesEveryRegisteredPasskeyAndAnActualEmptyList() =
runBlocking {
val service = ListPasskeyService()
val repository = PasskeyRepositoryImpl(service)
assertTrue(repository.getPasskeys().isEmpty())
service.response = BaseResponse(status = 200, code = 200, data = listOf(DTO, DTO.copy(id = 8L)))
assertEquals(listOf(7L, 8L), repository.getPasskeys().map { it.id })
}

@Test
fun list_failedOrMissingResponseDoesNotBecomeAnEmptyList() =
runBlocking {
val service = ListPasskeyService()
val repository = PasskeyRepositoryImpl(service)
service.response = BaseResponse(status = 500, code = 500, data = emptyList())
assertTrue(runCatching { repository.getPasskeys() }.isFailure)
service.response = BaseResponse(status = 200, code = 200, data = null)
assertTrue(runCatching { repository.getPasskeys() }.isFailure)
}

@Test(expected = SerializationException::class)
fun list_missingRequiredIdentityIsNotDefaulted() {
Json.decodeFromString<PasskeyDto>("""{"displayName":"Passkey","createdAt":"2026-09-06T10:00:00"}""")
}
}

private class ListPasskeyService : PasskeyApiService {
var response = BaseResponse(status = 200, code = 200, data = emptyList<PasskeyDto>())

override suspend fun getPasskeys(): BaseResponse<List<PasskeyDto>> = response

override suspend fun getRegistrationOptions(): BaseResponse<JsonElement> = error("Not used")

override suspend fun registerPasskey(credential: JsonElement): BaseResponse<PasskeyDto> = error("Not used")
}

private val DTO = PasskeyDto(id = 7L, displayName = "Passkey", createdAt = "2026-09-06T10:00:00")
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.afternote.feature.setting.data

import com.afternote.feature.setting.domain.Passkey
import com.afternote.feature.setting.domain.PasskeyRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import jakarta.inject.Singleton
import java.io.IOException
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger

/** app 계측은 이 fixture를 통해 internal 프로덕션 모듈을 대체한다. */
@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [PasskeyModule::class])
public object PasskeyTestModule {
@Provides
@Singleton
public fun provideScenario(): PasskeyTestScenario = PasskeyTestScenario()

@Provides
public fun providePasskeyRepository(scenario: PasskeyTestScenario): PasskeyRepository = scenario
}

public class PasskeyTestScenario : PasskeyRepository {
@Volatile
public var passkeys: List<Passkey> = emptyList()

@Volatile
public var listFails: Boolean = false

public val listCalls: AtomicInteger = AtomicInteger()
public val optionsCalls: AtomicInteger = AtomicInteger()
public val registeredCredentials: CopyOnWriteArrayList<String> = CopyOnWriteArrayList<String>()

override suspend fun getPasskeys(): List<Passkey> {
listCalls.incrementAndGet()
if (listFails) throw IOException("Controlled passkey list failure")
return passkeys
}

override suspend fun getRegistrationOptions(): String {
optionsCalls.incrementAndGet()
return """
{
"challenge": "YWZ0ZXJub3RlLXRlc3QtY2hhbGxlbmdl",
"rp": {"name": "Afternote test", "id": "example.test"},
"user": {"id": "dGVzdA", "name": "test@example.test", "displayName": "Test"},
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
"timeout": 1000,
"attestation": "none"
}
""".trimIndent()
}

override suspend fun registerPasskey(credentialJson: String): Passkey {
registeredCredentials += credentialJson
error("Real credentials must not be registered by this fixture")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.afternote.feature.setting.domain

public data class Passkey(
val id: Long,
val displayName: String,
val createdAt: String,
)

/** 설정의 패스키 목록·등록 계약. 서버가 자격 증명 등록 여부의 정본이다. */
public interface PasskeyRepository {
public suspend fun getPasskeys(): List<Passkey>

/** Credential Manager에 전달할 PublicKeyCredentialCreationOptions JSON. */
public suspend fun getRegistrationOptions(): String

/** Credential Manager가 생성한 PublicKeyCredential JSON을 서버에서 검증·등록한다. */
public suspend fun registerPasskey(credentialJson: String): Passkey
}
Loading
Loading