diff --git a/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AfternoteAppLinkParser.kt b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AfternoteAppLinkParser.kt new file mode 100644 index 0000000000..1e39430538 --- /dev/null +++ b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AfternoteAppLinkParser.kt @@ -0,0 +1,162 @@ +package com.afternote.core.common.deeplink + +import java.net.URI + +/** + * 링크 문자열 하나를 [NavigationTarget] 으로 옮기는 단일 관문 (#924). + * + * 브라우저 App Link(`Intent.getData()`)와 FCM `data.link` 가 **같은 함수**를 지난다. 입력을 + * `String` 으로 받는 이유가 그것이다 — 한쪽은 `android.net.Uri`, 다른 쪽은 payload 의 문자열이라 + * 공통분모가 문자열뿐이고, 문자열로 받아야 이 모듈이 Android 플랫폼 타입에도 묶이지 않는다 + * (그래서 이 파서의 테스트는 Robolectric 없이 도는 순수 JVM 테스트다). + * + * ## 계약 + * + * | 항목 | 값 | + * |---|---| + * | scheme | `https` 만 | + * | host | `afternote.kro.kr` 만 (대소문자 무시, userinfo 금지, 포트는 생략 또는 443) | + * | path | 아래 표의 정규 경로만. 소문자·끝 슬래시 없음·퍼센트 인코딩 없음 | + * | query | 지원하는 키 없음 — 붙으면 거절 | + * | fragment | 지원 안 함 — 붙으면 거절 | + * + * | 경로 | 목적지 | ID 형식 | 관문 | + * |---|---|---|---| + * | `/` | [NavigationTarget.Home] | — | 로그인 | + * | `/afternote` | [NavigationTarget.AfternoteHome] | — | 로그인 + 지문 | + * | `/afternote/{id}` | [NavigationTarget.AfternoteDetail] | 양의 10진 정수 | 로그인 + 지문 | + * | `/timeletter/{id}` | [NavigationTarget.TimeLetterDetail] | 양의 10진 정수 | 로그인 | + * | `/mindrecord/daily-question` | [NavigationTarget.DailyQuestionCompose] | — | 로그인 | + * | `/settings/notification` | [NavigationTarget.NotificationSettings] | — | 로그인 | + * + * 수신자 경로의 서버 식별자·접근 관문은 #1951에서 확정한다. 현재 지원 목적지에 포함하지 않는다. + * + * canonical URL 은 `https://afternote.kro.kr` + 경로다([canonicalUrl]). + * + * ## 왜 이렇게까지 좁히나 + * + * 링크는 **앱 밖에서 오는 입력**이다. 관대하게 받으면 그만큼이 공격면이다 — 커스텀 scheme 은 + * Digital Asset Links 검증을 못 받아 아무 앱이나 선점할 수 있고, `https://evil@afternote.kro.kr/` + * 같은 userinfo 는 눈으로 우리 도메인처럼 보인다. 그래서 계약에 **명시된 것만** 통과시키고 나머지는 + * 전부 [AppLinkResolution.Rejected] 다. 모르는 입력을 추측해서 여는 경로는 없다. + */ +object AfternoteAppLinkParser { + /** 검증된 App Link 의 유일한 scheme. */ + const val CANONICAL_SCHEME: String = "https" + + /** 검증된 App Link 의 유일한 host. `assetlinks.json` 이 이 도메인에 올라가 있다(Afternote-BE#228). */ + const val CANONICAL_HOST: String = "afternote.kro.kr" + + /** [target] 을 가리키는 정규 URL. `parse(canonicalUrl(t)) == Resolved(t)` 가 목적지 전량에서 성립한다. */ + fun canonicalUrl(target: NavigationTarget): String = "$CANONICAL_SCHEME://$CANONICAL_HOST${target.canonicalPath}" + + /** + * [rawLink] 를 목적지로 옮긴다. 계약 밖이면 사유와 안전한 기본 진입을 담은 + * [AppLinkResolution.Rejected] 다 — 예외를 던지지 않으며 `null` 도 없다. + */ + fun parse(rawLink: String?): AppLinkResolution { + val trimmed = rawLink?.trim().orEmpty() + if (trimmed.isEmpty()) return reject(AppLinkRejectionReason.MALFORMED_URI) + + val uri = runCatching { URI(trimmed) }.getOrNull() ?: return reject(AppLinkRejectionReason.MALFORMED_URI) + + if (!CANONICAL_SCHEME.equals(uri.scheme, ignoreCase = true)) { + return reject(AppLinkRejectionReason.UNSUPPORTED_SCHEME) + } + if (!CANONICAL_HOST.equals(uri.host, ignoreCase = true) || + uri.rawUserInfo != null || + uri.port !in ACCEPTED_PORTS + ) { + return reject(AppLinkRejectionReason.UNSUPPORTED_HOST) + } + if (uri.rawQuery != null) return reject(AppLinkRejectionReason.UNSUPPORTED_QUERY) + if (uri.rawFragment != null) return reject(AppLinkRejectionReason.UNSUPPORTED_FRAGMENT) + + val segments = + uri.rawPath.orEmpty().toCanonicalSegments() + ?: return reject(AppLinkRejectionReason.UNKNOWN_PATH) + + return resolvePath(segments) + } + + private fun resolvePath(segments: List): AppLinkResolution = + when { + segments.isEmpty() -> { + AppLinkResolution.Resolved(NavigationTarget.Home) + } + + segments == listOf(SEGMENT_AFTERNOTE) -> { + AppLinkResolution.Resolved(NavigationTarget.AfternoteHome) + } + + segments.size == 2 && segments[0] == SEGMENT_AFTERNOTE -> { + segments[1].resolveWithId(NavigationTarget::AfternoteDetail) + } + + segments.size == 2 && segments[0] == SEGMENT_TIMELETTER -> { + segments[1].resolveWithId(NavigationTarget::TimeLetterDetail) + } + + segments == listOf(SEGMENT_MINDRECORD, SEGMENT_DAILY_QUESTION) -> { + AppLinkResolution.Resolved(NavigationTarget.DailyQuestionCompose) + } + + segments == listOf(SEGMENT_SETTINGS, SEGMENT_NOTIFICATION) -> { + AppLinkResolution.Resolved(NavigationTarget.NotificationSettings) + } + + else -> { + reject(AppLinkRejectionReason.UNKNOWN_PATH) + } + } + + private fun String.resolveWithId(target: (Long) -> NavigationTarget): AppLinkResolution = + toResourceIdOrNull() + ?.let { AppLinkResolution.Resolved(target(it)) } + ?: reject(AppLinkRejectionReason.MALFORMED_ID) + + /** + * 정규 경로 세그먼트 목록. 계약 밖 문자가 하나라도 있으면 `null` 이다. + * + * 빈 세그먼트를 버리지 않고 **거절**하는 것이 요점이다 — 버리면 `/afternote//1` 이나 + * `/afternote/1/` 이 정규형과 같은 목적지가 되어 canonical URL 이 하나가 아니게 된다. + */ + private fun String.toCanonicalSegments(): List? { + if (isEmpty() || this == "/") return emptyList() + if (!startsWith('/')) return null + + val segments = drop(1).split('/') + return segments.takeIf { parsed -> parsed.all(SEGMENT_FORMAT::matches) } + } + + /** + * 양의 10진 정수 ID. 앞자리 `0`·부호·공백·비-ASCII 숫자는 전부 거절이다. + * + * 자릿수 판정에 정규식 `\d` 를 쓰지 않는다 — Android 의 `\d` 는 유니코드 숫자(예: 아라비아-인도 + * 숫자)까지 물어 서버 JVM 과 다르게 판정한다. `'0'..'9'` 로 직접 좁힌다. + */ + private fun String.toResourceIdOrNull(): Long? { + if (isEmpty() || length > MAX_ID_LENGTH) return null + if (any { character -> character !in '0'..'9' }) return null + if (length > 1 && first() == '0') return null + return toLongOrNull()?.takeIf { id -> id > 0L } + } + + private fun reject(reason: AppLinkRejectionReason): AppLinkResolution = AppLinkResolution.Rejected(reason) + + /** 생략(`-1`)이거나 https 기본 포트만 허용한다. 다른 포트는 우리 서비스가 서지 않는다. */ + private val ACCEPTED_PORTS = setOf(-1, 443) + + /** 정규 경로 세그먼트는 소문자·숫자·하이픈뿐이다. 대문자·퍼센트 인코딩·점은 정규형이 아니다. */ + private val SEGMENT_FORMAT = Regex("[a-z0-9-]+") + + /** `Long.MAX_VALUE` 의 자릿수. 넘으면 `toLongOrNull` 이전에 잘라 낸다. */ + private const val MAX_ID_LENGTH = 19 + + private const val SEGMENT_AFTERNOTE = "afternote" + private const val SEGMENT_TIMELETTER = "timeletter" + private const val SEGMENT_MINDRECORD = "mindrecord" + private const val SEGMENT_DAILY_QUESTION = "daily-question" + private const val SEGMENT_SETTINGS = "settings" + private const val SEGMENT_NOTIFICATION = "notification" +} diff --git a/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AppLinkRejectionReason.kt b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AppLinkRejectionReason.kt new file mode 100644 index 0000000000..090cba36ce --- /dev/null +++ b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AppLinkRejectionReason.kt @@ -0,0 +1,39 @@ +package com.afternote.core.common.deeplink + +/** + * 링크를 목적지로 못 옮긴 이유 (#924). + * + * fail-closed 의 «관측 가능한» 쪽을 담당한다. 거절은 조용히 홈으로 보내는 것으로 끝나면 안 된다 — + * 서버가 새 경로를 먼저 배포했는지, ID 형식을 잘못 만들었는지, 남이 우리 도메인을 흉내 냈는지는 + * 이유가 남아야 갈린다. + * + * [reportValue] 는 리포팅 속성에 싣는 고정 문자열이다. enum 이름을 바꿔도 이 값은 유지해야 한다 — + * 대시보드 집계가 옛 값으로 쌓여 있다. + * + * **원본 링크 자체는 절대 싣지 않는다.** 경로에 애프터노트·타임레터 ID 가 들어 있어 그대로 로그에 + * 남기면 식별 가능한 사용자 자료가 리포팅으로 새 나간다. 관측에 쓰는 값은 이 이유뿐이다. + */ +enum class AppLinkRejectionReason( + val reportValue: String, +) { + /** 비어 있거나 URI 로 파싱조차 되지 않는다. */ + MALFORMED_URI("malformed_uri"), + + /** `https` 가 아니다. 커스텀 scheme 은 Digital Asset Links 검증을 못 받아 아무 앱이나 가로챌 수 있다. */ + UNSUPPORTED_SCHEME("unsupported_scheme"), + + /** 우리 도메인이 아니거나, 호스트 앞에 userinfo 가 붙어 도메인을 흉내 냈다. */ + UNSUPPORTED_HOST("unsupported_host"), + + /** 계약 표에 없는 경로다. 끝 슬래시·빈 세그먼트·퍼센트 인코딩 변형도 여기로 온다. */ + UNKNOWN_PATH("unknown_path"), + + /** 경로 모양은 계약에 있는데 ID 가 형식을 벗어났다. */ + MALFORMED_ID("malformed_id"), + + /** 아직 지원하는 query 가 없다. 하나라도 붙어 오면 거절이다. */ + UNSUPPORTED_QUERY("unsupported_query"), + + /** fragment 는 목적지를 바꾸지 않는데 붙어 왔다 — 계약 밖 입력이다. */ + UNSUPPORTED_FRAGMENT("unsupported_fragment"), +} diff --git a/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AppLinkResolution.kt b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AppLinkResolution.kt new file mode 100644 index 0000000000..2145f65503 --- /dev/null +++ b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AppLinkResolution.kt @@ -0,0 +1,31 @@ +package com.afternote.core.common.deeplink + +/** + * 링크 한 건을 해석한 결과 (#924). + * + * `null` 을 돌려주고 소비처가 폴백을 고르게 두지 않는다. 그렇게 두면 호출부마다 폴백이 갈리고, + * 「왜 홈으로 왔는지」가 아무 데도 남지 않는다. 거절도 **값**이라 [Rejected] 가 안전한 기본 진입과 + * 사유를 함께 들고 온다 — 소비처는 `when` 두 갈래로 끝내면 되고, 사유를 버릴지 리포팅할지만 고른다. + */ +sealed interface AppLinkResolution { + /** 소비처가 실제로 이동할 목적지. 거절이어도 [Rejected.fallback] 이 있어 항상 값이 있다. */ + val target: NavigationTarget + + /** 계약 표의 한 행으로 해석됐다. */ + data class Resolved( + override val target: NavigationTarget, + ) : AppLinkResolution + + /** + * 계약 밖이라 거절했다. [fallback] 으로 보내되 [reason] 을 관측에 남긴다. + * + * [fallback] 은 [NavigationTarget.Home] 이라 여전히 로그인 관문을 지난다 — 거절이 관문을 + * 건너뛰는 우회로가 되지 않는다. + */ + data class Rejected( + val reason: AppLinkRejectionReason, + val fallback: NavigationTarget = NavigationTarget.Home, + ) : AppLinkResolution { + override val target: NavigationTarget get() = fallback + } +} diff --git a/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AuthGate.kt b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AuthGate.kt new file mode 100644 index 0000000000..c2af78ef76 --- /dev/null +++ b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AuthGate.kt @@ -0,0 +1,19 @@ +package com.afternote.core.common.deeplink + +/** + * 목적지에 들어가기 전에 통과해야 하는 인증 관문 (#924). + * + * 순서가 의미를 갖는다 — `ordinal` 이 낮을수록 먼저 통과해야 하는 관문이다. 작성자 지문 인증은 로그인 뒤에 확인하므로 [LOGIN] 이 항상 맨 앞이고, 그 불변식은 + * `NavigationTargetContractTest` 가 목적지 전량에 대해 잠근다. + * + * 관문을 **판정**하는 것과 관문을 **띄우는** 것은 다른 일이다. 이 enum 과 + * [NavigationTarget.requiredGates] 는 앞엣것만 소유한다 — 어떤 화면으로 관문을 띄우고 통과 후 + * 어떻게 원래 목적지를 재개할지는 앱 루트가 갖는다. + */ +enum class AuthGate { + /** 앱 계정 로그인. 모든 목적지의 최초 관문이다. */ + LOGIN, + + /** 지문(생체) 인증. 작성자 본인의 애프터노트 열람 경로에 걸린다. */ + BIOMETRIC, +} diff --git a/core/common/src/main/kotlin/com/afternote/core/common/deeplink/NavigationTarget.kt b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/NavigationTarget.kt new file mode 100644 index 0000000000..8fcdc62fcd --- /dev/null +++ b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/NavigationTarget.kt @@ -0,0 +1,60 @@ +package com.afternote.core.common.deeplink + +/** + * App Link·FCM `data.link` 가 가리키는 **엔진 중립** 목적지 (#924). + * + * Nav2 `Route` 도 Navigation 3 `NavKey` 도 아니다. 링크 계약은 서버·브라우저·알림이 공유하는 외부 + * 계약이라 앱 안의 navigation 엔진 교체(#1698·#1702)와 수명이 다르다 — 둘을 같은 타입으로 묶으면 + * 엔진을 갈아탈 때 외부 계약이 함께 흔들린다. 그래서 이 모듈은 화면 그래프를 알지 않고, 실제 + * 화면 매핑은 앱 루트의 navigator adapter 가 갖는다(`NotificationDestination` 과 같은 이유·같은 층). + * + * [canonicalPath] 는 이 목적지를 가리키는 **유일한** 정규 경로다. 파서는 정규형만 받는다 — + * 끝 슬래시·대문자·퍼센트 인코딩 변형은 전부 거절이다([AfternoteAppLinkParser]). + * + * @property canonicalPath `/` 로 시작하고 끝 슬래시가 없는 정규 경로. + * @property requiredGates 진입 전 통과해야 하는 관문. [AuthGate] 순서대로 오름차순이며 비어 있지 않다. + */ +sealed interface NavigationTarget { + val canonicalPath: String + val requiredGates: List + + /** 링크가 사이트 루트를 가리킬 때의 목적지이자, 거절된 링크의 안전한 기본 진입([AppLinkResolution.Rejected.fallback]). */ + data object Home : NavigationTarget { + override val canonicalPath: String = "/" + override val requiredGates: List = listOf(AuthGate.LOGIN) + } + + /** 작성자 애프터노트 홈. */ + data object AfternoteHome : NavigationTarget { + override val canonicalPath: String = "/afternote" + override val requiredGates: List = listOf(AuthGate.LOGIN, AuthGate.BIOMETRIC) + } + + /** 작성자 애프터노트 상세. */ + data class AfternoteDetail( + val afternoteId: Long, + ) : NavigationTarget { + override val canonicalPath: String = "/afternote/$afternoteId" + override val requiredGates: List = listOf(AuthGate.LOGIN, AuthGate.BIOMETRIC) + } + + /** 타임레터 상세. */ + data class TimeLetterDetail( + val timeLetterId: Long, + ) : NavigationTarget { + override val canonicalPath: String = "/timeletter/$timeLetterId" + override val requiredGates: List = listOf(AuthGate.LOGIN) + } + + /** 데일리질문 작성. */ + data object DailyQuestionCompose : NavigationTarget { + override val canonicalPath: String = "/mindrecord/daily-question" + override val requiredGates: List = listOf(AuthGate.LOGIN) + } + + /** 알림 설정. */ + data object NotificationSettings : NavigationTarget { + override val canonicalPath: String = "/settings/notification" + override val requiredGates: List = listOf(AuthGate.LOGIN) + } +} diff --git a/core/common/src/test/java/com/afternote/core/common/deeplink/AfternoteAppLinkParserTest.kt b/core/common/src/test/java/com/afternote/core/common/deeplink/AfternoteAppLinkParserTest.kt new file mode 100644 index 0000000000..2d8e99fc02 --- /dev/null +++ b/core/common/src/test/java/com/afternote/core/common/deeplink/AfternoteAppLinkParserTest.kt @@ -0,0 +1,200 @@ +package com.afternote.core.common.deeplink + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * URI 계약 표의 모든 행과 fail-closed 거절 전량을 잠근다 (#924). + * + * 표의 «있는 행»만 세면 계약이 넓어지는 것을 못 잡는다. 그래서 거절 쪽도 사유별로 단언한다 — + * 알 수 없는 path·잘못된 ID·미지원 query 는 각각 다른 이유로 거절돼야 한다. + */ +class AfternoteAppLinkParserTest { + @Test + fun `계약 표의 모든 행이 목적지로 해석된다`() { + val rows = + mapOf( + "https://afternote.kro.kr/" to NavigationTarget.Home, + "https://afternote.kro.kr/afternote" to NavigationTarget.AfternoteHome, + "https://afternote.kro.kr/afternote/42" to NavigationTarget.AfternoteDetail(42L), + "https://afternote.kro.kr/timeletter/9" to NavigationTarget.TimeLetterDetail(9L), + "https://afternote.kro.kr/mindrecord/daily-question" to + NavigationTarget.DailyQuestionCompose, + "https://afternote.kro.kr/settings/notification" to + NavigationTarget.NotificationSettings, + ) + + rows.forEach { (link, expected) -> + assertEquals(link, AppLinkResolution.Resolved(expected), AfternoteAppLinkParser.parse(link)) + } + } + + @Test + fun `호스트 없는 사이트 루트도 홈이다`() { + assertEquals( + AppLinkResolution.Resolved(NavigationTarget.Home), + AfternoteAppLinkParser.parse("https://afternote.kro.kr"), + ) + } + + @Test + fun `scheme 과 host 는 대소문자를 무시한다`() { + assertEquals( + AppLinkResolution.Resolved(NavigationTarget.AfternoteHome), + AfternoteAppLinkParser.parse("HTTPS://AFTERNOTE.KRO.KR/afternote"), + ) + } + + @Test + fun `기본 포트 443 은 생략과 같다`() { + assertEquals( + AppLinkResolution.Resolved(NavigationTarget.AfternoteHome), + AfternoteAppLinkParser.parse("https://afternote.kro.kr:443/afternote"), + ) + } + + @Test + fun `앞뒤 공백은 다듬어 해석한다`() { + assertEquals( + AppLinkResolution.Resolved(NavigationTarget.AfternoteHome), + AfternoteAppLinkParser.parse(" https://afternote.kro.kr/afternote\n"), + ) + } + + @Test + fun `알 수 없는 path 는 거절이다`() { + listOf( + "https://afternote.kro.kr/unknown", + "https://afternote.kro.kr/afternote/42/extra", + "https://afternote.kro.kr/received/senders", + "https://afternote.kro.kr/mindrecord", + "https://afternote.kro.kr/settings", + "https://afternote.kro.kr/Afternote", + "https://afternote.kro.kr/afternote/", + "https://afternote.kro.kr/afternote//42", + "https://afternote.kro.kr/%2Fafternote", + "https://afternote.kro.kr/afternote/../settings/notification", + ).forEach { link -> + assertEquals(link, rejectedBecause(AppLinkRejectionReason.UNKNOWN_PATH), AfternoteAppLinkParser.parse(link)) + } + } + + @Test + fun `잘못된 ID 는 거절이다`() { + listOf( + "https://afternote.kro.kr/afternote/abc", + "https://afternote.kro.kr/afternote/0", + "https://afternote.kro.kr/afternote/007", + "https://afternote.kro.kr/afternote/99999999999999999999", + "https://afternote.kro.kr/timeletter/-1", + ).forEach { link -> + assertEquals(link, rejectedBecause(AppLinkRejectionReason.MALFORMED_ID), AfternoteAppLinkParser.parse(link)) + } + } + + /** + * 유니코드 숫자는 ID 가 아니다. Android 정규식의 `\d` 는 이것들을 숫자로 물어 서버 JVM 과 + * 다르게 판정하므로, 파서가 `'0'..'9'` 로 직접 좁히는지 여기서 잠근다. + */ + @Test + fun `아라비아-인도 숫자는 ID 가 아니다`() { + assertEquals( + rejectedBecause(AppLinkRejectionReason.UNKNOWN_PATH), + AfternoteAppLinkParser.parse("https://afternote.kro.kr/afternote/١٢"), + ) + } + + @Test + fun `지원하지 않는 query 는 거절이다`() { + listOf( + "https://afternote.kro.kr/afternote?utm_source=mail", + "https://afternote.kro.kr/afternote/42?ref=push", + "https://afternote.kro.kr/afternote?", + ).forEach { link -> + assertEquals( + link, + rejectedBecause(AppLinkRejectionReason.UNSUPPORTED_QUERY), + AfternoteAppLinkParser.parse(link), + ) + } + } + + @Test + fun `fragment 는 거절이다`() { + assertEquals( + rejectedBecause(AppLinkRejectionReason.UNSUPPORTED_FRAGMENT), + AfternoteAppLinkParser.parse("https://afternote.kro.kr/afternote#top"), + ) + } + + @Test + fun `https 가 아닌 scheme 은 거절이다`() { + listOf( + "http://afternote.kro.kr/afternote", + "afternote://afternote.kro.kr/afternote", + "/afternote", + "mailto:hello@afternote.kro.kr", + ).forEach { link -> + assertEquals( + link, + rejectedBecause(AppLinkRejectionReason.UNSUPPORTED_SCHEME), + AfternoteAppLinkParser.parse(link), + ) + } + } + + @Test + fun `다른 host 와 userinfo 위장은 거절이다`() { + listOf( + "https://evil.example.com/afternote", + "https://afternote.kro.kr.evil.example.com/afternote", + "https://sub.afternote.kro.kr/afternote", + "https://evil@afternote.kro.kr/afternote", + "https://afternote.kro.kr:8443/afternote", + "https:///afternote", + ).forEach { link -> + assertEquals( + link, + rejectedBecause(AppLinkRejectionReason.UNSUPPORTED_HOST), + AfternoteAppLinkParser.parse(link), + ) + } + } + + @Test + fun `비어 있거나 파싱되지 않는 입력은 거절이다`() { + listOf(null, "", " ", "https://afternote.kro.kr/after note", "::::").forEach { link -> + assertEquals( + "$link", + rejectedBecause(AppLinkRejectionReason.MALFORMED_URI), + AfternoteAppLinkParser.parse(link), + ) + } + } + + @Test + fun `거절의 기본 진입은 홈이고 로그인 관문을 지난다`() { + val rejected = AfternoteAppLinkParser.parse("https://afternote.kro.kr/unknown") + + assertEquals(NavigationTarget.Home, rejected.target) + assertEquals(listOf(AuthGate.LOGIN), rejected.target.requiredGates) + } + + @Test + fun `미확정 수신자 경로는 목적지로 선언하지 않는다`() { + listOf( + "/received", + "/received/senders/3f2504e0-4f89-41d3-9a0c-0305e82c3301", + "/received/afternote/7", + ).forEach { path -> + assertEquals( + path, + rejectedBecause(AppLinkRejectionReason.UNKNOWN_PATH), + AfternoteAppLinkParser.parse("https://afternote.kro.kr$path"), + ) + } + } + + private fun rejectedBecause(reason: AppLinkRejectionReason): AppLinkResolution = + AppLinkResolution.Rejected(reason, NavigationTarget.Home) +} diff --git a/core/common/src/test/java/com/afternote/core/common/deeplink/NavigationTargetContractTest.kt b/core/common/src/test/java/com/afternote/core/common/deeplink/NavigationTargetContractTest.kt new file mode 100644 index 0000000000..ad0c1c3563 --- /dev/null +++ b/core/common/src/test/java/com/afternote/core/common/deeplink/NavigationTargetContractTest.kt @@ -0,0 +1,99 @@ +package com.afternote.core.common.deeplink + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 목적지 계약의 불변식을 전량 훑는다 (#924). + * + * 목적지를 새로 더할 때 여기서 걸리게 하는 것이 목적이다. [expectedGates] 의 `when` 이 exhaustive라 + * 새 목적지를 만들면 **컴파일이 먼저 막는다** — 관문을 정하지 않은 목적지가 계약에 실릴 수 없고, + * 그 `when` 을 고치러 오면 바로 옆 [ALL_TARGETS] 도 같이 눈에 든다. + */ +class NavigationTargetContractTest { + @Test + fun `표에 적힌 관문 조합 그대로다`() { + ALL_TARGETS.forEach { target -> + assertEquals("$target", expectedGates(target), target.requiredGates) + } + } + + @Test + fun `관문은 로그인부터 시작해 오름차순이고 중복이 없다`() { + ALL_TARGETS.forEach { target -> + val gates = target.requiredGates + + assertEquals("$target 의 첫 관문", AuthGate.LOGIN, gates.firstOrNull()) + assertEquals("$target 의 관문 중복", gates.size, gates.toSet().size) + assertEquals("$target 의 관문 순서", gates.sortedBy(AuthGate::ordinal), gates) + } + } + + @Test + fun `정규 경로는 슬래시로 시작하고 끝나지 않는다`() { + ALL_TARGETS.forEach { target -> + val path = target.canonicalPath + + assertTrue("$target 의 경로 시작", path.startsWith('/')) + assertTrue("$target 의 경로 끝", path == "/" || !path.endsWith('/')) + } + } + + @Test + fun `정규 경로는 목적지마다 서로 다르다`() { + val paths = ALL_TARGETS.map(NavigationTarget::canonicalPath) + + assertEquals(paths.size, paths.toSet().size) + } + + /** 계약의 왕복 — 표를 통해 나간 URL 은 반드시 같은 목적지로 돌아온다. */ + @Test + fun `정규 URL 은 같은 목적지로 되돌아온다`() { + ALL_TARGETS.forEach { target -> + val url = AfternoteAppLinkParser.canonicalUrl(target) + + assertEquals(url, AppLinkResolution.Resolved(target), AfternoteAppLinkParser.parse(url)) + } + } + + @Test + fun `정규 URL 은 확정된 scheme 과 host 를 쓴다`() { + assertEquals( + "https://afternote.kro.kr/settings/notification", + AfternoteAppLinkParser.canonicalUrl(NavigationTarget.NotificationSettings), + ) + } + + @Test + fun `리포팅 값은 서로 다르고 비어 있지 않다`() { + val reportValues = AppLinkRejectionReason.entries.map(AppLinkRejectionReason::reportValue) + + assertEquals(reportValues.size, reportValues.toSet().size) + assertEquals(emptyList(), reportValues.filter(String::isBlank)) + } + + /** 이슈 #924 경로 표의 «필요한 게이트» 열 원문. 목적지가 늘면 컴파일로 막힌다. */ + private fun expectedGates(target: NavigationTarget): List = + when (target) { + NavigationTarget.Home -> listOf(AuthGate.LOGIN) + NavigationTarget.AfternoteHome -> listOf(AuthGate.LOGIN, AuthGate.BIOMETRIC) + is NavigationTarget.AfternoteDetail -> listOf(AuthGate.LOGIN, AuthGate.BIOMETRIC) + is NavigationTarget.TimeLetterDetail -> listOf(AuthGate.LOGIN) + NavigationTarget.DailyQuestionCompose -> listOf(AuthGate.LOGIN) + NavigationTarget.NotificationSettings -> listOf(AuthGate.LOGIN) + } + + private companion object { + /** 목적지 전량. 인자를 갖는 목적지는 대표값 하나로 센다. */ + val ALL_TARGETS: List = + listOf( + NavigationTarget.Home, + NavigationTarget.AfternoteHome, + NavigationTarget.AfternoteDetail(42L), + NavigationTarget.TimeLetterDetail(9L), + NavigationTarget.DailyQuestionCompose, + NavigationTarget.NotificationSettings, + ) + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt index 93a157b588..efd569624c 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt @@ -387,6 +387,9 @@ class ProductionVisibilityKonsistTest { "app/src/main/java/com/afternote/afternote_fe/notification/" + "NotificationIntentContract.kt::class NotificationEntrySource [internal]" to "NotificationEntryRequest.source의 내부 계약 타입", + "core/common/src/main/kotlin/com/afternote/core/common/deeplink/" + + "AfternoteAppLinkParser.kt::object AfternoteAppLinkParser [implicit-public]" to + "App Link·FCM data.link 가 공유하는 URI 계약 진입점 — intent 수신 배선(#924)이 붙기 전까지 소비처가 테스트뿐", "app/src/main/java/com/afternote/afternote_fe/update/" + "ForceUpdateGate.kt::class ForceUpdatePrompt [implicit-public]" to "ForceUpdateGate.prompt 가 노출하고 MainActivity 가 타입 추론으로 소비",