From 41c8781edb814733af01087c8aee73d3b7fb5d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 3 Sep 2026 20:19:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20App=20Link=20URI=20=EB=A5=BC?= =?UTF-8?q?=20=EC=97=94=EC=A7=84=20=EC=A4=91=EB=A6=BD=20=EB=AA=A9=EC=A0=81?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EB=8A=94=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20=EC=84=B8=EC=9A=B4=EB=8B=A4=20(#1857)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저 App Link 와 FCM data.link 가 같은 파서를 지나도록 입력을 String 으로 받는다. 목적지는 Nav2 Route 도 Navigation 3 NavKey 도 아닌 sealed NavigationTarget 이다 — 링크는 서버·브라우저·알림이 공유하는 외부 계약이라 앱 안의 navigation 엔진 교체(#1698·#1702)와 수명이 다르다. 계약 밖 입력은 전부 거절이다. 링크는 앱 밖에서 오는 입력이라 관대하게 받는 만큼이 공격면이다 — 커스텀 scheme 은 Digital Asset Links 검증을 못 받아 아무 앱이나 선점할 수 있고, https://evil@afternote.kro.kr/ 같은 userinfo 는 눈으로 우리 도메인처럼 보인다. 거절은 null 이 아니라 사유와 안전한 기본 진입을 함께 든 값이라, 호출부마다 폴백이 갈리거나 「왜 홈으로 왔는지」가 사라지지 않는다. ID 자릿수 판정에 정규식 \d 를 쓰지 않는다. Android 의 \d 는 아라비아-인도 숫자까지 물어 서버 JVM 과 다르게 판정하므로 '0'..'9' 로 직접 좁혔고, 그 경계를 테스트로 잠갔다. 파서 진입점은 아직 프로덕션 소비처가 없어 konsist 의 의도된 계약 목록에 넣는다. intent 수신 배선은 #924 가 붙인다. --- .../common/deeplink/AfternoteAppLinkParser.kt | 184 ++++++++++++++++ .../common/deeplink/AppLinkRejectionReason.kt | 39 ++++ .../core/common/deeplink/AppLinkResolution.kt | 31 +++ .../core/common/deeplink/AuthGate.kt | 23 ++ .../core/common/deeplink/NavigationTarget.kt | 90 ++++++++ .../deeplink/AfternoteAppLinkParserTest.kt | 200 ++++++++++++++++++ .../deeplink/NavigationTargetContractTest.kt | 107 ++++++++++ .../ProductionVisibilityKonsistTest.kt | 3 + 8 files changed, 677 insertions(+) create mode 100644 core/common/src/main/kotlin/com/afternote/core/common/deeplink/AfternoteAppLinkParser.kt create mode 100644 core/common/src/main/kotlin/com/afternote/core/common/deeplink/AppLinkRejectionReason.kt create mode 100644 core/common/src/main/kotlin/com/afternote/core/common/deeplink/AppLinkResolution.kt create mode 100644 core/common/src/main/kotlin/com/afternote/core/common/deeplink/AuthGate.kt create mode 100644 core/common/src/main/kotlin/com/afternote/core/common/deeplink/NavigationTarget.kt create mode 100644 core/common/src/test/java/com/afternote/core/common/deeplink/AfternoteAppLinkParserTest.kt create mode 100644 core/common/src/test/java/com/afternote/core/common/deeplink/NavigationTargetContractTest.kt 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..8951d97c85 --- /dev/null +++ b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AfternoteAppLinkParser.kt @@ -0,0 +1,184 @@ +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진 정수 | 로그인 + 지문 | + * | `/received` | [NavigationTarget.ReceivedRecordBox] | — | 로그인 | + * | `/received/senders/{senderId}` | [NavigationTarget.ReceivedSenderDetail] | 소문자 UUID | 로그인 | + * | `/received/afternote/{id}` | [NavigationTarget.ReceivedAfternoteDetail] | 양의 10진 정수 | 로그인 + 수신자 본인인증 | + * | `/timeletter/{id}` | [NavigationTarget.TimeLetterDetail] | 양의 10진 정수 | 로그인 | + * | `/mindrecord/daily-question` | [NavigationTarget.DailyQuestionCompose] | — | 로그인 | + * | `/settings/notification` | [NavigationTarget.NotificationSettings] | — | 로그인 | + * + * 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 == listOf(SEGMENT_RECEIVED) -> { + AppLinkResolution.Resolved(NavigationTarget.ReceivedRecordBox) + } + + segments.size == 3 && segments[0] == SEGMENT_RECEIVED && segments[1] == SEGMENT_SENDERS -> { + segments[2] + .takeIf(SENDER_ID_FORMAT::matches) + ?.let { AppLinkResolution.Resolved(NavigationTarget.ReceivedSenderDetail(it)) } + ?: reject(AppLinkRejectionReason.MALFORMED_ID) + } + + segments.size == 3 && segments[0] == SEGMENT_RECEIVED && segments[1] == SEGMENT_AFTERNOTE -> { + segments[2].resolveWithId(NavigationTarget::ReceivedAfternoteDetail) + } + + 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-]+") + + /** `SenderRegistry` 가 만드는 소문자 canonical UUID. */ + private val SENDER_ID_FORMAT = + Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + + /** `Long.MAX_VALUE` 의 자릿수. 넘으면 `toLongOrNull` 이전에 잘라 낸다. */ + private const val MAX_ID_LENGTH = 19 + + private const val SEGMENT_AFTERNOTE = "afternote" + private const val SEGMENT_RECEIVED = "received" + private const val SEGMENT_SENDERS = "senders" + 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..5d9013d1ca --- /dev/null +++ b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/AuthGate.kt @@ -0,0 +1,23 @@ +package com.afternote.core.common.deeplink + +/** + * 목적지에 들어가기 전에 통과해야 하는 인증 관문 (#924). + * + * 순서가 의미를 갖는다 — `ordinal` 이 낮을수록 먼저 통과해야 하는 관문이다. 로그인 없이 지문이나 + * 수신자 본인인증을 물을 수 없으므로 [LOGIN] 이 항상 맨 앞이고, 그 불변식은 + * `NavigationTargetGateTest` 가 목적지 전량에 대해 잠근다. + * + * 관문을 **판정**하는 것과 관문을 **띄우는** 것은 다른 일이다. 이 enum 과 + * [NavigationTarget.requiredGates] 는 앞엣것만 소유한다 — 어떤 화면으로 관문을 띄우고 통과 후 + * 어떻게 원래 목적지를 재개할지는 앱 루트가 갖는다. + */ +enum class AuthGate { + /** 앱 계정 로그인. 모든 목적지의 최초 관문이다. */ + LOGIN, + + /** 지문(생체) 인증. 작성자 본인의 애프터노트 열람 경로에 걸린다. */ + BIOMETRIC, + + /** 수신자 본인인증 — 발신자별 이메일 인증 + 마스터 키. 받은 애프터노트 열람에 걸린다. */ + RECEIVER_IDENTITY, +} 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..69f2e9fdc8 --- /dev/null +++ b/core/common/src/main/kotlin/com/afternote/core/common/deeplink/NavigationTarget.kt @@ -0,0 +1,90 @@ +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 object ReceivedRecordBox : NavigationTarget { + override val canonicalPath: String = "/received" + override val requiredGates: List = listOf(AuthGate.LOGIN) + } + + /** + * 발신자 상세. + * + * [senderId] 는 지금 **프로세스 메모리에만 사는 클라이언트 로컬 UUID** 다(`SenderRegistry`, #215 — + * 발신자 라벨 API 가 아직 없다). 그래서 이 경로는 형식이 맞아도 앱 밖에서 온 링크로는 사실상 + * 해석되지 않는다. 파서는 **형식만** 판정하고, 실제 조회 실패는 소비처가 fail-closed 로 처리한다. + * 서버 발신자 식별자가 생기면 이 행의 ID 형식을 그때 다시 확정한다. + */ + data class ReceivedSenderDetail( + val senderId: String, + ) : NavigationTarget { + override val canonicalPath: String = "/received/senders/$senderId" + override val requiredGates: List = listOf(AuthGate.LOGIN) + } + + /** 수신 애프터노트 상세 — 발신자별 본인인증을 통과해야 열린다. */ + data class ReceivedAfternoteDetail( + val afternoteId: Long, + ) : NavigationTarget { + override val canonicalPath: String = "/received/afternote/$afternoteId" + override val requiredGates: List = + listOf(AuthGate.LOGIN, AuthGate.RECEIVER_IDENTITY) + } + + /** 타임레터 상세. */ + 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..244cff35c9 --- /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/received" to NavigationTarget.ReceivedRecordBox, + "https://afternote.kro.kr/received/senders/$SENDER_ID" to + NavigationTarget.ReceivedSenderDetail(SENDER_ID), + "https://afternote.kro.kr/received/afternote/7" to + NavigationTarget.ReceivedAfternoteDetail(7L), + "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.ReceivedRecordBox), + AfternoteAppLinkParser.parse(" https://afternote.kro.kr/received\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", + // 대문자 UUID 는 정규 세그먼트가 아니라 ID 판정에 닿기 전에 걸린다. + "https://afternote.kro.kr/received/senders/${SENDER_ID.uppercase()}", + ).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", + "https://afternote.kro.kr/received/afternote/1e3", + "https://afternote.kro.kr/received/senders/not-a-uuid", + "https://afternote.kro.kr/received/senders/${SENDER_ID.dropLast(1)}", + "https://afternote.kro.kr/received/senders/${SENDER_ID.replace('-', '0')}", + ).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/received?", + ).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) + } + + private fun rejectedBecause(reason: AppLinkRejectionReason): AppLinkResolution = + AppLinkResolution.Rejected(reason, NavigationTarget.Home) + + private companion object { + const val SENDER_ID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + } +} 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..8800bb3a84 --- /dev/null +++ b/core/common/src/test/java/com/afternote/core/common/deeplink/NavigationTargetContractTest.kt @@ -0,0 +1,107 @@ +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) + NavigationTarget.ReceivedRecordBox -> listOf(AuthGate.LOGIN) + is NavigationTarget.ReceivedSenderDetail -> listOf(AuthGate.LOGIN) + is NavigationTarget.ReceivedAfternoteDetail -> listOf(AuthGate.LOGIN, AuthGate.RECEIVER_IDENTITY) + is NavigationTarget.TimeLetterDetail -> listOf(AuthGate.LOGIN) + NavigationTarget.DailyQuestionCompose -> listOf(AuthGate.LOGIN) + NavigationTarget.NotificationSettings -> listOf(AuthGate.LOGIN) + } + + private companion object { + const val SENDER_ID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + + /** 목적지 전량. 인자를 갖는 목적지는 대표값 하나로 센다. */ + val ALL_TARGETS: List = + listOf( + NavigationTarget.Home, + NavigationTarget.AfternoteHome, + NavigationTarget.AfternoteDetail(42L), + NavigationTarget.ReceivedRecordBox, + NavigationTarget.ReceivedSenderDetail(SENDER_ID), + NavigationTarget.ReceivedAfternoteDetail(7L), + 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 b51b77e408..0da9f52927 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt @@ -389,6 +389,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)이 붙기 전까지 소비처가 테스트뿐", "core/ui/src/main/kotlin/com/afternote/core/ui/button/" + "AfternoteRadioGroup.kt::function AfternoteRadioGroup [implicit-public]" to "core:ui README에 문서화된 공용 컴포넌트", From 4e1383d7123dc6654145a834d5bdb5cdd48680bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 8 Sep 2026 13:19:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(core):=20=EB=AF=B8=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=88=98=EC=8B=A0=EC=9E=90=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=9D=84=20=EC=84=A0=ED=96=89=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EC=97=90=EC=84=9C=20=EB=B6=84=EB=A6=AC=20(#1857)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/deeplink/AfternoteAppLinkParser.kt | 26 ++------------ .../core/common/deeplink/AuthGate.kt | 8 ++--- .../core/common/deeplink/NavigationTarget.kt | 30 ---------------- .../deeplink/AfternoteAppLinkParserTest.kt | 36 +++++++++---------- .../deeplink/NavigationTargetContractTest.kt | 8 ----- 5 files changed, 22 insertions(+), 86 deletions(-) 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 index 8951d97c85..1e39430538 100644 --- 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 @@ -25,13 +25,12 @@ import java.net.URI * | `/` | [NavigationTarget.Home] | — | 로그인 | * | `/afternote` | [NavigationTarget.AfternoteHome] | — | 로그인 + 지문 | * | `/afternote/{id}` | [NavigationTarget.AfternoteDetail] | 양의 10진 정수 | 로그인 + 지문 | - * | `/received` | [NavigationTarget.ReceivedRecordBox] | — | 로그인 | - * | `/received/senders/{senderId}` | [NavigationTarget.ReceivedSenderDetail] | 소문자 UUID | 로그인 | - * | `/received/afternote/{id}` | [NavigationTarget.ReceivedAfternoteDetail] | 양의 10진 정수 | 로그인 + 수신자 본인인증 | * | `/timeletter/{id}` | [NavigationTarget.TimeLetterDetail] | 양의 10진 정수 | 로그인 | * | `/mindrecord/daily-question` | [NavigationTarget.DailyQuestionCompose] | — | 로그인 | * | `/settings/notification` | [NavigationTarget.NotificationSettings] | — | 로그인 | * + * 수신자 경로의 서버 식별자·접근 관문은 #1951에서 확정한다. 현재 지원 목적지에 포함하지 않는다. + * * canonical URL 은 `https://afternote.kro.kr` + 경로다([canonicalUrl]). * * ## 왜 이렇게까지 좁히나 @@ -94,21 +93,6 @@ object AfternoteAppLinkParser { segments[1].resolveWithId(NavigationTarget::AfternoteDetail) } - segments == listOf(SEGMENT_RECEIVED) -> { - AppLinkResolution.Resolved(NavigationTarget.ReceivedRecordBox) - } - - segments.size == 3 && segments[0] == SEGMENT_RECEIVED && segments[1] == SEGMENT_SENDERS -> { - segments[2] - .takeIf(SENDER_ID_FORMAT::matches) - ?.let { AppLinkResolution.Resolved(NavigationTarget.ReceivedSenderDetail(it)) } - ?: reject(AppLinkRejectionReason.MALFORMED_ID) - } - - segments.size == 3 && segments[0] == SEGMENT_RECEIVED && segments[1] == SEGMENT_AFTERNOTE -> { - segments[2].resolveWithId(NavigationTarget::ReceivedAfternoteDetail) - } - segments.size == 2 && segments[0] == SEGMENT_TIMELETTER -> { segments[1].resolveWithId(NavigationTarget::TimeLetterDetail) } @@ -166,16 +150,10 @@ object AfternoteAppLinkParser { /** 정규 경로 세그먼트는 소문자·숫자·하이픈뿐이다. 대문자·퍼센트 인코딩·점은 정규형이 아니다. */ private val SEGMENT_FORMAT = Regex("[a-z0-9-]+") - /** `SenderRegistry` 가 만드는 소문자 canonical UUID. */ - private val SENDER_ID_FORMAT = - Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") - /** `Long.MAX_VALUE` 의 자릿수. 넘으면 `toLongOrNull` 이전에 잘라 낸다. */ private const val MAX_ID_LENGTH = 19 private const val SEGMENT_AFTERNOTE = "afternote" - private const val SEGMENT_RECEIVED = "received" - private const val SEGMENT_SENDERS = "senders" private const val SEGMENT_TIMELETTER = "timeletter" private const val SEGMENT_MINDRECORD = "mindrecord" private const val SEGMENT_DAILY_QUESTION = "daily-question" 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 index 5d9013d1ca..c2af78ef76 100644 --- 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 @@ -3,9 +3,8 @@ package com.afternote.core.common.deeplink /** * 목적지에 들어가기 전에 통과해야 하는 인증 관문 (#924). * - * 순서가 의미를 갖는다 — `ordinal` 이 낮을수록 먼저 통과해야 하는 관문이다. 로그인 없이 지문이나 - * 수신자 본인인증을 물을 수 없으므로 [LOGIN] 이 항상 맨 앞이고, 그 불변식은 - * `NavigationTargetGateTest` 가 목적지 전량에 대해 잠근다. + * 순서가 의미를 갖는다 — `ordinal` 이 낮을수록 먼저 통과해야 하는 관문이다. 작성자 지문 인증은 로그인 뒤에 확인하므로 [LOGIN] 이 항상 맨 앞이고, 그 불변식은 + * `NavigationTargetContractTest` 가 목적지 전량에 대해 잠근다. * * 관문을 **판정**하는 것과 관문을 **띄우는** 것은 다른 일이다. 이 enum 과 * [NavigationTarget.requiredGates] 는 앞엣것만 소유한다 — 어떤 화면으로 관문을 띄우고 통과 후 @@ -17,7 +16,4 @@ enum class AuthGate { /** 지문(생체) 인증. 작성자 본인의 애프터노트 열람 경로에 걸린다. */ BIOMETRIC, - - /** 수신자 본인인증 — 발신자별 이메일 인증 + 마스터 키. 받은 애프터노트 열람에 걸린다. */ - RECEIVER_IDENTITY, } 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 index 69f2e9fdc8..8fcdc62fcd 100644 --- 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 @@ -38,36 +38,6 @@ sealed interface NavigationTarget { override val requiredGates: List = listOf(AuthGate.LOGIN, AuthGate.BIOMETRIC) } - /** 받은 기록함 — 수신자가 등록한 발신자 카드 목록. */ - data object ReceivedRecordBox : NavigationTarget { - override val canonicalPath: String = "/received" - override val requiredGates: List = listOf(AuthGate.LOGIN) - } - - /** - * 발신자 상세. - * - * [senderId] 는 지금 **프로세스 메모리에만 사는 클라이언트 로컬 UUID** 다(`SenderRegistry`, #215 — - * 발신자 라벨 API 가 아직 없다). 그래서 이 경로는 형식이 맞아도 앱 밖에서 온 링크로는 사실상 - * 해석되지 않는다. 파서는 **형식만** 판정하고, 실제 조회 실패는 소비처가 fail-closed 로 처리한다. - * 서버 발신자 식별자가 생기면 이 행의 ID 형식을 그때 다시 확정한다. - */ - data class ReceivedSenderDetail( - val senderId: String, - ) : NavigationTarget { - override val canonicalPath: String = "/received/senders/$senderId" - override val requiredGates: List = listOf(AuthGate.LOGIN) - } - - /** 수신 애프터노트 상세 — 발신자별 본인인증을 통과해야 열린다. */ - data class ReceivedAfternoteDetail( - val afternoteId: Long, - ) : NavigationTarget { - override val canonicalPath: String = "/received/afternote/$afternoteId" - override val requiredGates: List = - listOf(AuthGate.LOGIN, AuthGate.RECEIVER_IDENTITY) - } - /** 타임레터 상세. */ data class TimeLetterDetail( val timeLetterId: Long, 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 index 244cff35c9..2d8e99fc02 100644 --- 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 @@ -17,11 +17,6 @@ class AfternoteAppLinkParserTest { "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/received" to NavigationTarget.ReceivedRecordBox, - "https://afternote.kro.kr/received/senders/$SENDER_ID" to - NavigationTarget.ReceivedSenderDetail(SENDER_ID), - "https://afternote.kro.kr/received/afternote/7" to - NavigationTarget.ReceivedAfternoteDetail(7L), "https://afternote.kro.kr/timeletter/9" to NavigationTarget.TimeLetterDetail(9L), "https://afternote.kro.kr/mindrecord/daily-question" to NavigationTarget.DailyQuestionCompose, @@ -61,8 +56,8 @@ class AfternoteAppLinkParserTest { @Test fun `앞뒤 공백은 다듬어 해석한다`() { assertEquals( - AppLinkResolution.Resolved(NavigationTarget.ReceivedRecordBox), - AfternoteAppLinkParser.parse(" https://afternote.kro.kr/received\n"), + AppLinkResolution.Resolved(NavigationTarget.AfternoteHome), + AfternoteAppLinkParser.parse(" https://afternote.kro.kr/afternote\n"), ) } @@ -79,8 +74,6 @@ class AfternoteAppLinkParserTest { "https://afternote.kro.kr/afternote//42", "https://afternote.kro.kr/%2Fafternote", "https://afternote.kro.kr/afternote/../settings/notification", - // 대문자 UUID 는 정규 세그먼트가 아니라 ID 판정에 닿기 전에 걸린다. - "https://afternote.kro.kr/received/senders/${SENDER_ID.uppercase()}", ).forEach { link -> assertEquals(link, rejectedBecause(AppLinkRejectionReason.UNKNOWN_PATH), AfternoteAppLinkParser.parse(link)) } @@ -94,10 +87,6 @@ class AfternoteAppLinkParserTest { "https://afternote.kro.kr/afternote/007", "https://afternote.kro.kr/afternote/99999999999999999999", "https://afternote.kro.kr/timeletter/-1", - "https://afternote.kro.kr/received/afternote/1e3", - "https://afternote.kro.kr/received/senders/not-a-uuid", - "https://afternote.kro.kr/received/senders/${SENDER_ID.dropLast(1)}", - "https://afternote.kro.kr/received/senders/${SENDER_ID.replace('-', '0')}", ).forEach { link -> assertEquals(link, rejectedBecause(AppLinkRejectionReason.MALFORMED_ID), AfternoteAppLinkParser.parse(link)) } @@ -120,7 +109,7 @@ class AfternoteAppLinkParserTest { listOf( "https://afternote.kro.kr/afternote?utm_source=mail", "https://afternote.kro.kr/afternote/42?ref=push", - "https://afternote.kro.kr/received?", + "https://afternote.kro.kr/afternote?", ).forEach { link -> assertEquals( link, @@ -191,10 +180,21 @@ class AfternoteAppLinkParserTest { 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) - - private companion object { - const val SENDER_ID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" - } } 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 index 8800bb3a84..ad0c1c3563 100644 --- 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 @@ -79,26 +79,18 @@ class NavigationTargetContractTest { NavigationTarget.Home -> listOf(AuthGate.LOGIN) NavigationTarget.AfternoteHome -> listOf(AuthGate.LOGIN, AuthGate.BIOMETRIC) is NavigationTarget.AfternoteDetail -> listOf(AuthGate.LOGIN, AuthGate.BIOMETRIC) - NavigationTarget.ReceivedRecordBox -> listOf(AuthGate.LOGIN) - is NavigationTarget.ReceivedSenderDetail -> listOf(AuthGate.LOGIN) - is NavigationTarget.ReceivedAfternoteDetail -> listOf(AuthGate.LOGIN, AuthGate.RECEIVER_IDENTITY) is NavigationTarget.TimeLetterDetail -> listOf(AuthGate.LOGIN) NavigationTarget.DailyQuestionCompose -> listOf(AuthGate.LOGIN) NavigationTarget.NotificationSettings -> listOf(AuthGate.LOGIN) } private companion object { - const val SENDER_ID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" - /** 목적지 전량. 인자를 갖는 목적지는 대표값 하나로 센다. */ val ALL_TARGETS: List = listOf( NavigationTarget.Home, NavigationTarget.AfternoteHome, NavigationTarget.AfternoteDetail(42L), - NavigationTarget.ReceivedRecordBox, - NavigationTarget.ReceivedSenderDetail(SENDER_ID), - NavigationTarget.ReceivedAfternoteDetail(7L), NavigationTarget.TimeLetterDetail(9L), NavigationTarget.DailyQuestionCompose, NavigationTarget.NotificationSettings,