feat(access): 접근 정책 모델을 추가한다

This commit is contained in:
2026-07-14 20:44:13 +09:00
parent 98db0b657e
commit 4d51806cee
5 changed files with 241 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.access
sealed interface AccessDecision {
data object Allowed : AccessDecision
data object LoginRequired : AccessDecision
data object AdultVerificationRequired : AccessDecision
data object AdultContentSettingRequired : AccessDecision
}

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.v2.access
class AccessPolicy {
fun decide(requirement: AccessRequirement, state: AccessState): AccessDecision {
return when (requirement) {
AccessRequirement.Login -> decideLogin(state)
AccessRequirement.AdultContent -> decideAdultContent(state)
}
}
private fun decideLogin(state: AccessState): AccessDecision {
return if (state.token.isNotBlank() && state.token.length > LOGIN_TOKEN_MIN_LENGTH) {
AccessDecision.Allowed
} else {
AccessDecision.LoginRequired
}
}
private companion object {
const val LOGIN_TOKEN_MIN_LENGTH = 10
}
private fun decideAdultContent(state: AccessState): AccessDecision {
val loginDecision = decideLogin(state)
if (loginDecision != AccessDecision.Allowed) return loginDecision
val isKoreanCountry = state.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !state.isAuth) {
return AccessDecision.AdultVerificationRequired
}
if (!state.isAdultContentVisible) {
return AccessDecision.AdultContentSettingRequired
}
return AccessDecision.Allowed
}
}

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.v2.access
sealed interface AccessRequirement {
data object Login : AccessRequirement
data object AdultContent : AccessRequirement
companion object {
fun forAdultContent(requiresAdultContentAccess: Boolean): AccessRequirement {
return if (requiresAdultContentAccess) AdultContent else Login
}
}
}

View File

@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.v2.access
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
data class AccessState(
val token: String,
val countryCode: String,
val isAuth: Boolean,
val isAdultContentVisible: Boolean
)
fun interface AccessStateProvider {
fun current(): AccessState
}
class SharedPreferenceAccessStateProvider : AccessStateProvider {
override fun current(): AccessState = AccessState(
token = SharedPreferenceManager.token,
countryCode = SharedPreferenceManager.countryCode,
isAuth = SharedPreferenceManager.isAuth,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible
)
}

View File

@@ -0,0 +1,160 @@
package kr.co.vividnext.sodalive.v2.access
import org.junit.Assert.assertEquals
import org.junit.Test
class AccessPolicyTest {
private val policy = AccessPolicy()
@Test
fun `로그인 요구사항은 토큰이 비어 있으면 로그인이 필요하다`() {
val decision = policy.decide(
requirement = AccessRequirement.Login,
state = accessState(token = "")
)
assertEquals(AccessDecision.LoginRequired, decision)
}
@Test
fun `로그인 요구사항은 토큰이 10자 이하면 로그인이 필요하다`() {
val decision = policy.decide(
requirement = AccessRequirement.Login,
state = accessState(token = "1234567890")
)
assertEquals(AccessDecision.LoginRequired, decision)
}
@Test
fun `로그인 요구사항은 토큰이 11자 이상 공백이면 로그인이 필요하다`() {
val decision = policy.decide(
requirement = AccessRequirement.Login,
state = accessState(token = " ")
)
assertEquals(AccessDecision.LoginRequired, decision)
}
@Test
fun `로그인 요구사항은 토큰이 11자 이상이면 성인 조건을 확인하지 않고 허용한다`() {
val decision = policy.decide(
requirement = AccessRequirement.Login,
state = accessState(
token = "12345678901",
countryCode = "KR",
isAuth = false,
isAdultContentVisible = false
)
)
assertEquals(AccessDecision.Allowed, decision)
}
@Test
fun `성인 콘텐츠 요구사항은 토큰이 10자 이하면 로그인이 필요하다`() {
val decision = policy.decide(
requirement = AccessRequirement.AdultContent,
state = accessState(token = "1234567890")
)
assertEquals(AccessDecision.LoginRequired, decision)
}
@Test
fun `성인 콘텐츠 요구사항은 빈 국가 코드를 KR로 처리한다`() {
val decision = policy.decide(
requirement = AccessRequirement.AdultContent,
state = accessState(
countryCode = "",
isAuth = false,
isAdultContentVisible = true
)
)
assertEquals(AccessDecision.AdultVerificationRequired, decision)
}
@Test
fun `성인 콘텐츠 요구사항은 국내 미인증 사용자의 본인인증이 필요하다`() {
val decision = policy.decide(
requirement = AccessRequirement.AdultContent,
state = accessState(
countryCode = "KR",
isAuth = false,
isAdultContentVisible = true
)
)
assertEquals(AccessDecision.AdultVerificationRequired, decision)
}
@Test
fun `성인 콘텐츠 요구사항은 본인인증 필요 결과를 성인 콘텐츠 설정보다 먼저 반환한다`() {
val decision = policy.decide(
requirement = AccessRequirement.AdultContent,
state = accessState(
countryCode = "KR",
isAuth = false,
isAdultContentVisible = false
)
)
assertEquals(AccessDecision.AdultVerificationRequired, decision)
}
@Test
fun `성인 콘텐츠 요구사항은 국외 사용자의 국내 본인인증을 건너뛴다`() {
val decision = policy.decide(
requirement = AccessRequirement.AdultContent,
state = accessState(
countryCode = "US",
isAuth = false,
isAdultContentVisible = true
)
)
assertEquals(AccessDecision.Allowed, decision)
}
@Test
fun `성인 콘텐츠 요구사항은 성인 콘텐츠 표시 설정이 꺼져 있으면 설정이 필요하다`() {
val decision = policy.decide(
requirement = AccessRequirement.AdultContent,
state = accessState(
countryCode = "US",
isAuth = false,
isAdultContentVisible = false
)
)
assertEquals(AccessDecision.AdultContentSettingRequired, decision)
}
@Test
fun `성인 콘텐츠 요구사항은 모든 조건을 충족하면 허용한다`() {
val decision = policy.decide(
requirement = AccessRequirement.AdultContent,
state = accessState(
countryCode = "KR",
isAuth = true,
isAdultContentVisible = true
)
)
assertEquals(AccessDecision.Allowed, decision)
}
private fun accessState(
token: String = "12345678901",
countryCode: String = "KR",
isAuth: Boolean = true,
isAdultContentVisible: Boolean = true
): AccessState = AccessState(
token = token,
countryCode = countryCode,
isAuth = isAuth,
isAdultContentVisible = isAdultContentVisible
)
}