diff --git a/core/data/src/main/java/com/example/slowclock/data/FirestoreCollections.kt b/core/data/src/main/java/com/example/slowclock/data/FirestoreCollections.kt index 9dcb1f58..e5b78845 100644 --- a/core/data/src/main/java/com/example/slowclock/data/FirestoreCollections.kt +++ b/core/data/src/main/java/com/example/slowclock/data/FirestoreCollections.kt @@ -4,12 +4,18 @@ package com.example.slowclock.data object FirestoreCollections { const val USERS = "users" - /** 이름·공유 코드만 담는 공개 프로필. `users` 는 본인만 읽는다. */ + /** 이름만 담는 공개 프로필. `users` 는 본인만 읽는다. */ const val PUBLIC_PROFILES = "publicProfiles" const val SCHEDULES = "schedules" const val NOTIFICATIONS = "notifications" const val SCHEDULE_RECOMMENDATIONS = "scheduleRecommendations" const val FAMILY_GROUPS = "familyGroups" + + /** + * 공유 코드 등록부. 코드 자체가 문서 이름이라 만들기 한 번으로 중복이 걸러진다. + * 아무도 읽지 못한다 — 코드 목록은 그 자체가 사람을 찾는 열쇠 꾸러미다(#174). + */ + const val SHARE_CODES = "shareCodes" const val SHARE_CODE_WATCHERS = "shareCodeWatchers" const val SHARE_CODE_WATCHER_TOKENS = "tokens" diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt index 61ce4ed3..cf9ea7ba 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt @@ -7,8 +7,13 @@ import com.example.slowclock.data.model.User import com.google.firebase.Timestamp import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.FirebaseFirestoreException +import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.toObject import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.tasks.await import javax.inject.Inject @@ -26,25 +31,30 @@ class UserRepository ) { private val usersCollection = firestore.collection(FirestoreCollections.USERS) private val publicProfilesCollection = firestore.collection(FirestoreCollections.PUBLIC_PROFILES) + private val shareCodesCollection = firestore.collection(FirestoreCollections.SHARE_CODES) - // 6-character code generator - private suspend fun generateUniqueShareCode(): String { + /** + * 아직 임자가 없는 여섯 자리 코드를 하나 만들어 [uid] 앞으로 등록한다. + * + * 등록부는 코드 자체가 문서 이름이라, 이미 있는 코드에 대한 만들기는 규칙이 거절한다. + * 확인과 저장이 한 번으로 합쳐지므로 그 사이에 같은 코드를 두 사람이 가져가는 틈이 없다. + * 질의로 확인하던 앞의 방식은 컬렉션을 훑을 권한을 함께 열어야 했다(#174). + */ + private suspend fun claimShareCode(uid: String): String { val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - while (true) { - val code = - (1..6) - .map { chars.random() } - .joinToString("") - // Check uniqueness in Firestore - val exists = - publicProfilesCollection - .whereEqualTo("shareCode", code) - .get() - .await() - .documents - .isNotEmpty() - if (!exists) return code + repeat(SHARE_CODE_CLAIM_ATTEMPTS) { + val code = (1..SHARE_CODE_LENGTH).map { chars.random() }.joinToString("") + try { + shareCodesCollection.document(code).set(mapOf("userId" to uid)).await() + return code + } catch (e: FirebaseFirestoreException) { + // 임자가 있는 코드였다. 그 밖의 실패는 다음 코드로 넘어가도 똑같이 실패하므로 + // 여기서 던져 부르는 쪽이 안내로 바꾸게 한다. + if (e.code != FirebaseFirestoreException.Code.PERMISSION_DENIED) throw e + Log.d(TAG, "이미 쓰이는 공유 코드다. 다시 만든다") + } } + error("공유 코드를 ${SHARE_CODE_CLAIM_ATTEMPTS}번 만들어 봤지만 모두 임자가 있었다") } // 현재 로그인한 사용자 정보 가져오기 @@ -61,6 +71,18 @@ class UserRepository // 계정 삭제용: 사용자 문서 삭제 suspend fun deleteUserDocument(userId: String): Boolean = try { + // 공유 코드 등록부는 본인만 지울 수 있다. 사용자 문서를 먼저 지우면 코드를 알 + // 길이 없어져 등록부에 영영 남고, 그 코드는 아무도 다시 쓰지 못한다(#174). + val shareCode = + usersCollection + .document(userId) + .get() + .await() + .getString("shareCode") + .orEmpty() + if (shareCode.isNotBlank()) { + shareCodesCollection.document(shareCode).delete().await() + } usersCollection.document(userId).delete().await() publicProfilesCollection.document(userId).delete().await() true @@ -87,12 +109,12 @@ class UserRepository id = uid, name = name, email = email, - shareCode = generateUniqueShareCode(), + shareCode = claimShareCode(uid), createdAt = existing?.createdAt ?: Timestamp.now(), updatedAt = Timestamp.now(), ) usersCollection.document(uid).set(newUser).await() - savePublicProfile(newUser.id, newUser.name, newUser.shareCode) + savePublicProfile(newUser.id, newUser.name) } else { val updates = mutableMapOf() if (existing.name != name && name.isNotBlank()) updates["name"] = name @@ -102,7 +124,7 @@ class UserRepository usersCollection.document(uid).update(updates).await() } // 공개 프로필이 아직 없거나 이름이 바뀐 경우를 함께 맞춘다. - savePublicProfile(uid, if (name.isNotBlank()) name else existing.name, existing.shareCode) + savePublicProfile(uid, if (name.isNotBlank()) name else existing.name) } true } catch (e: Exception) { @@ -114,41 +136,52 @@ class UserRepository } /** - * 공개 프로필을 만들거나 갱신한다. 이름·공유 코드만 담으므로 다른 사용자가 읽어도 된다. + * 공개 프로필을 만들거나 갱신한다. 이름만 담으므로 다른 사용자가 읽어도 된다. * 실패해도 사용자 문서 저장 결과를 뒤집지 않는다. 다음 로그인에서 다시 맞춘다. */ private suspend fun savePublicProfile( uid: String, name: String, - shareCode: String, ) { - if (shareCode.isBlank()) return try { publicProfilesCollection .document(uid) - .set(PublicProfile(id = uid, name = name, shareCode = shareCode)) + .set(PublicProfile(id = uid, name = name)) .await() } catch (e: Exception) { Log.w(TAG, "공개 프로필 저장 실패", e) } } - /** 사용자 ID → 이름. Firestore `whereIn` 은 10개까지라 나눠 조회한다. */ + /** + * 사용자 ID → 이름. + * + * 문서를 하나씩 읽는다. 공개 프로필은 목록 조회를 막아 두었다 — 목록이 열려 있으면 문서 + * 이름을 몰라도 컬렉션을 통째로 훑을 수 있기 때문이다. 여기 들어오는 uid 는 이미 읽을 수 + * 있는 공유 일정에서 나온 값이라 하나씩 읽는 것으로 충분하다(#174). + */ suspend fun getUserNames(userIds: List): Map { if (userIds.isEmpty()) return emptyMap() return try { - val result = mutableMapOf() - userIds.distinct().chunked(FIRESTORE_WHERE_IN_LIMIT).forEach { chunk -> - publicProfilesCollection - .whereIn("id", chunk) - .get() - .await() - .documents - .mapNotNull { it.toObject() } - .forEach { result[it.id] = it.name } + coroutineScope { + userIds + .distinct() + .map { id -> + async { + id to + publicProfilesCollection + .document(id) + .get() + .await() + .getString("name") + .orEmpty() + } + }.awaitAll() + .filter { (_, name) -> name.isNotBlank() } + .toMap() } - result } catch (e: Exception) { + Log.w(TAG, "공유 일정 소유자 이름 조회 실패", e) emptyMap() } } @@ -166,12 +199,22 @@ class UserRepository } } - /** 공유 코드 감시자로 현재 기기 토큰을 등록한다. 공유 일정 변경 알림을 받기 위한 것이다. */ + /** + * 공유 코드 감시자로 이 기기를 등록한다. + * + * 이 문서가 있다는 것이 곧 「그 코드를 받은 사람」 이라는 표시라, 공유 일정을 읽을 권한의 + * 근거가 된다. 그래서 FCM 토큰을 못 받아도 등록 자체는 한다. 토큰이 없으면 알림만 못 받고, + * 등록을 거르면 가족 일정이 통째로 안 보인다(#174). + */ suspend fun registerShareCodeWatcher(shareCode: String): Boolean { val uid = auth.currentUser?.uid ?: return false return try { - val token = messaging.token.await() - shareCodeWatcherTokens(shareCode).document(uid).set(mapOf("fcmToken" to token)).await() + val token = runCatching { messaging.token.await() }.getOrNull() + if (token == null) Log.w(TAG, "FCM 토큰 없이 감시자를 등록한다. 알림은 다음 등록에서 붙는다") + val fields = mutableMapOf("userId" to uid) + token?.let { fields["fcmToken"] = it } + // merge 로 쓴다. 토큰을 못 받은 등록이 앞서 저장해 둔 토큰을 지우면 안 된다. + shareCodeWatcherTokens(shareCode).document(uid).set(fields, SetOptions.merge()).await() true } catch (e: Exception) { Log.e(TAG, "공유 코드 감시자 등록 실패", e) @@ -197,6 +240,7 @@ class UserRepository .collection(FirestoreCollections.SHARE_CODE_WATCHER_TOKENS) private companion object { - const val FIRESTORE_WHERE_IN_LIMIT = 10 + const val SHARE_CODE_LENGTH = 6 + const val SHARE_CODE_CLAIM_ATTEMPTS = 8 } } diff --git a/core/model/src/main/java/com/example/slowclock/data/model/PublicProfile.kt b/core/model/src/main/java/com/example/slowclock/data/model/PublicProfile.kt index fdc28731..14e03393 100644 --- a/core/model/src/main/java/com/example/slowclock/data/model/PublicProfile.kt +++ b/core/model/src/main/java/com/example/slowclock/data/model/PublicProfile.kt @@ -1,14 +1,15 @@ package com.example.slowclock.data.model /** - * 다른 사용자에게 보여도 되는 값만 담는다. 공유 일정의 소유자 이름을 보여 주고 공유 코드로 사람을 - * 찾는 데 쓴다. + * 다른 사용자에게 보여도 되는 값만 담는다. 공유 일정의 소유자 이름을 보여 주는 데 쓴다. * * `users` 문서에는 이메일과 FCM 토큰이 함께 있어 통째로 열 수 없다. Firestore 는 필드 단위 읽기 * 제한이 없으므로 공개할 값만 따로 둔다. + * + * 공유 코드는 여기에 두지 않는다. 사람을 찾는 열쇠라 이름 옆에 둘 값이 아니고, 코드를 문서 이름으로 + * 삼는 등록부(`shareCodes`)가 그 자리를 대신한다(#174). */ data class PublicProfile( val id: String = "", val name: String = "", - val shareCode: String = "", ) diff --git a/docs/firestore.md b/docs/firestore.md index 6d79e852..b83d0359 100644 --- a/docs/firestore.md +++ b/docs/firestore.md @@ -7,8 +7,9 @@ | 경로 | 담는 값 | 읽기 | 쓰기 | |---|---|---|---| | `users/{uid}` | 이름, 이메일, 공유 코드, FCM 토큰, 생성·수정 시각 | 본인만 | 본인만 | -| `publicProfiles/{uid}` | id, 이름, 공유 코드 | 로그인한 사용자 전체 | 본인만, 이 세 필드만 | -| `schedules/{id}` | 일정 제목·설명·시각·완료 여부·소유자 uid·공유 코드 | 소유자, 그리고 `sharedCode` 가 있으면 로그인 사용자 | 소유자. 공유 일정은 `completed`·`updatedAt` 만 | +| `publicProfiles/{uid}` | id, 이름 | 로그인한 사용자가 문서 하나씩(`get`). 목록 조회(`list`)는 아무도 못 함 | 본인만, 이 두 필드만 | +| `shareCodes/{code}` | 그 코드의 임자 uid | 아무도 못 함 | 만들기는 본인 이름으로 한 번만(이미 있으면 거절), 지우기는 임자만 | +| `schedules/{id}` | 일정 제목·설명·시각·완료 여부·소유자 uid·공유 코드 | 소유자, 그리고 그 `sharedCode` 의 감시자로 등록한 사람 | 소유자. 공유 일정은 감시자가 `completed`·`completedDates`·`updatedAt` 만 | | `notifications/{id}` | 알림 기록(소유자 uid) | 소유자 | 소유자 | | `shareCodeWatchers/{code}/tokens/{uid}` | 그 공유 코드를 보는 기기의 FCM 토큰 | 본인 문서만 | 본인 문서만 | | `scheduleRecommendations/{id}` | 유형별 추천 일정 | 로그인한 사용자 전체 | 아무도 못 함(콘솔로만) | @@ -19,7 +20,17 @@ Firestore 에는 필드 단위 읽기 제한이 없다. 문서 하나를 읽게 허용하면 그 문서의 모든 필드가 함께 나간다. 그래서 이름 하나를 보여 주려고 `users` 를 열면 이메일과 FCM 토큰도 같이 열린다. 실제로 그런 규칙이 배포돼 있었고 #95 에서 이 구조로 바꿨다. -`publicProfiles` 는 쓰기 규칙이 필드 목록을 `id`·`name`·`shareCode` 로 묶는다. 필드를 늘리려면 규칙과 테스트를 함께 고쳐야 한다. 그 마찰이 의도한 것이다. +`publicProfiles` 는 쓰기 규칙이 필드 목록을 `id`·`name` 으로 묶는다. 필드를 늘리려면 규칙과 테스트를 함께 고쳐야 한다. 그 마찰이 의도한 것이다. + +## 공유 범위를 무엇으로 정하는가 + +「로그인했는가」 는 범위가 아니다. Google 계정은 누구나 몇 초 만에 새로 만들 수 있어, 로그인만 조건으로 두면 그 컬렉션은 사실상 공개다. 그리고 읽기 권한을 문서 하나 단위로만 생각하면 부족하다. `get` 을 열면 「문서 이름을 아는 사람」 이 읽지만, `list` 까지 열면 「아무것도 모르는 사람」 이 컬렉션을 통째로 가져간다. + +그래서 이 저장소는 세 가지를 지킨다(#174). + +1. 사람을 찾는 열쇠(공유 코드)는 훑을 수 있는 자리에 두지 않는다. `shareCodes` 는 코드가 문서 이름이고 읽기를 아무에게도 열지 않는다. 중복 확인은 만들기 한 번으로 끝나므로, 확인과 저장 사이에 같은 코드를 두 사람이 가져가는 틈도 없다. +2. 공유 일정은 「공유로 표시됐는가」 가 아니라 「그 코드의 감시자로 등록한 사람인가」 로 연다. 등록 문서(`shareCodeWatchers/{code}/tokens/{uid}`)가 그 관계의 증거다. 이 문서는 읽기 권한의 근거이기도 해서, FCM 토큰을 못 받아도 등록 자체는 한다. +3. 읽기보다 쓰기를 더 좁게 본다. 남이 완료 표시를 뒤집을 수 있으면 어르신이 약을 이미 먹은 줄로 안다. 이 앱에서는 그쪽이 더 아픈 피해다. ## 서버가 관리자 권한으로 읽는 곳 @@ -50,6 +61,8 @@ firebase deploy --only firestore:indexes 배포 전에 `npm test` 로 규칙 테스트를 돌린다. 규칙을 좁히는 배포는 앱의 읽기 경로를 끊을 수 있어, 배포 뒤 기기에서 로그인·일정 조회·공유 코드 입력까지 확인한다. +공유 읽기 규칙은 문서마다 감시자 등록을 확인한다. 규칙의 문서 접근 횟수에는 한도가 있으므로, 공유 일정이 늘어도 목록 조회가 막히지 않는지 규칙 테스트가 실제 건수로 재 둔다. + ## Play 데이터 보안 선언과의 관계 [`play-release.md`](play-release.md) 의 데이터 보안 표가 수집·공유 항목의 정본이다. 컬렉션을 늘리거나 담는 값을 바꾸면 그 표와 [`privacy.html`](privacy.html) 을 함께 고친다. 규칙이 선언보다 넓으면 선언이 거짓이 된다. diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt index 1df9988a..64ecb7d0 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt @@ -296,6 +296,12 @@ class MainViewModel if (uid == null || shareCode.isNullOrBlank()) { flowOf(emptyList()) } else { + // 감시자 등록이 곧 공유 일정을 읽을 권한이다. 코드는 저장됐는데 등록만 + // 실패한 채로 남아 있으면 가족 일정이 영영 비어 보이므로 구독 전에 다시 + // 세운다. 같은 문서에 다시 쓰는 것이라 값은 그대로고, 그 사이 바뀐 FCM + // 토큰이 함께 갱신된다(#174). + val registered = userRepository.registerShareCodeWatcher(shareCode) + if (!registered) Log.w(TAG, "감시자 등록에 실패해 공유 일정을 못 읽을 수 있다") scheduleRepository.observeSchedulesBySharedCode(shareCode).catch { e -> Log.e(TAG, "공유 일정 구독 실패", e) emit(emptyList()) diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt index 0e0420ec..479b417d 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -67,5 +68,15 @@ internal fun ShareCodeContent( ) { Text(if (state.isSaving) "저장 중..." else "저장하고 돌아가기") } + // 등록이 곧 읽기 권한이라, 실패를 알리지 않으면 가족 일정이 왜 비어 있는지 알 수 없다(#174). + if (state.saveError != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = state.saveError, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } } } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeContract.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeContract.kt index 17bf12e9..f0a96705 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeContract.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeContract.kt @@ -14,11 +14,17 @@ sealed interface ShareCodeIntent : MviIntent { data object ConsumeSaved : ShareCodeIntent } -/** [isSaved] 는 일회성 신호다. 화면이 소비하고 [ShareCodeIntent.ConsumeSaved] 로 되돌린다. */ +/** + * [isSaved] 는 일회성 신호다. 화면이 소비하고 [ShareCodeIntent.ConsumeSaved] 로 되돌린다. + * + * [saveError] 는 신호가 아니라 화면에 남는 값이다. 잠깐 떴다 사라지는 안내로는 이 화면에서 할 + * 일을 알 수 없다 — 저장이 안 됐다는 사실과 다시 눌러야 한다는 것이 계속 보여야 한다. + */ data class ShareCodeUiState( val input: String = "", val isSaving: Boolean = false, val isSaved: Boolean = false, + val saveError: String? = null, ) : UiState { val canSave: Boolean get() = input.isNotBlank() && !isSaving } @@ -33,4 +39,6 @@ sealed interface ShareCodeReducerEvent : ReducerEvent { data object Saved : ShareCodeReducerEvent data object SavedConsumed : ShareCodeReducerEvent + + data object SaveFailed : ShareCodeReducerEvent } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt index 46a9c6ca..5f3d219b 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt @@ -9,7 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject -/** 공유 코드 입력 화면. 코드를 기기에 저장하고 감시자 토큰을 등록한다. */ +/** 공유 코드 입력 화면. 감시자로 등록한 뒤 코드를 기기에 저장한다. */ @HiltViewModel class ShareCodeViewModel @Inject @@ -34,10 +34,28 @@ class ShareCodeViewModel event: ShareCodeReducerEvent, ): ShareCodeUiState = when (event) { - is ShareCodeReducerEvent.InputChanged -> state.copy(input = event.value) - ShareCodeReducerEvent.Saving -> state.copy(isSaving = true) - ShareCodeReducerEvent.Saved -> state.copy(isSaving = false, isSaved = true) - ShareCodeReducerEvent.SavedConsumed -> state.copy(isSaved = false) + is ShareCodeReducerEvent.InputChanged -> { + state.copy(input = event.value, saveError = null) + } + + ShareCodeReducerEvent.Saving -> { + state.copy(isSaving = true, saveError = null) + } + + ShareCodeReducerEvent.Saved -> { + state.copy(isSaving = false, isSaved = true) + } + + ShareCodeReducerEvent.SavedConsumed -> { + state.copy(isSaved = false) + } + + ShareCodeReducerEvent.SaveFailed -> { + state.copy( + isSaving = false, + saveError = "코드를 등록하지 못했습니다. 인터넷에 연결한 뒤 다시 눌러 주세요.", + ) + } } private fun save() { @@ -47,13 +65,20 @@ class ShareCodeViewModel val previousShareCode = settingsRepository.getShareCode() dispatch(ShareCodeReducerEvent.Saving) viewModelScope.launch { + // 감시자 등록이 곧 공유 일정을 읽을 권한이다. 등록에 실패한 코드를 기기에 저장하면 + // 가족 일정이 빈 채로 남고 사용자는 이유를 알 길이 없다. 그래서 등록이 된 뒤에 + // 저장한다. 실패하면 앞 코드도 그대로 둔다 — 새 코드가 안 됐는데 앞의 것까지 + // 잃으면 되돌아갈 자리가 없다(#174). + if (!userRepository.registerShareCodeWatcher(shareCode)) { + Log.w("ShareCodeWatcher", "watcher register failed shareCode=$shareCode") + dispatch(ShareCodeReducerEvent.SaveFailed) + return@launch + } if (!previousShareCode.isNullOrBlank() && previousShareCode != shareCode) { val unregistered = userRepository.unregisterShareCodeWatcher(previousShareCode) Log.d("ShareCodeWatcher", "watcher unregistered=$unregistered shareCode=$previousShareCode") } settingsRepository.setShareCode(shareCode) - val registered = userRepository.registerShareCodeWatcher(shareCode) - Log.d("ShareCodeWatcher", "watcher registered=$registered shareCode=$shareCode") dispatch(ShareCodeReducerEvent.Saved) } } diff --git a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt index 5acc12d9..65040f5c 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt @@ -57,6 +57,7 @@ class MainViewModelTest { every { settingsRepository.observeShareCode() } returns shareCode every { scheduleRepository.observeSchedulesBySharedCode(any()) } returns flowOf(emptyList()) coEvery { userRepository.getUserNames(any()) } returns emptyMap() + coEvery { userRepository.registerShareCodeWatcher(any()) } returns true // 기본값은 정시 알람이 허용된 기기다. 안내 다이얼로그를 다루는 테스트만 이 값을 뒤집는다. every { alarmScheduler.canScheduleExactAlarms() } returns true every { settingsRepository.hasSeenExactAlarmNotice() } returns false diff --git a/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt index 4508f186..47ada14d 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -65,6 +67,37 @@ class ShareCodeViewModelTest { assertFalse(viewModel.uiState.value.isSaved) } + @Test + fun `감시자 등록에 실패하면 코드를 저장하지 않고 앞 코드도 그대로 둔다`() = + runTest { + // 등록이 곧 공유 일정을 읽을 권한이다. 등록 못 한 코드를 저장하면 가족 일정이 빈 채로 + // 남고, 앞 코드까지 지우면 되돌아갈 자리도 없다(#174). + coEvery { userRepository.registerShareCodeWatcher("NEW002") } returns false + val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + + viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW002")) + viewModel.onIntent(ShareCodeIntent.Save) + + verify(exactly = 0) { settingsRepository.setShareCode(any()) } + coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any()) } + assertFalse(viewModel.uiState.value.isSaved) + assertFalse(viewModel.uiState.value.isSaving) + assertNotNull(viewModel.uiState.value.saveError) + } + + @Test + fun `입력을 고치면 실패 안내가 사라진다`() = + runTest { + coEvery { userRepository.registerShareCodeWatcher(any()) } returns false + val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + + viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW002")) + viewModel.onIntent(ShareCodeIntent.Save) + viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW003")) + + assertNull(viewModel.uiState.value.saveError) + } + @Test fun `빈 입력은 저장하지 않는다`() = runTest { diff --git a/firestore-tests/rules.test.mjs b/firestore-tests/rules.test.mjs index 235a37be..564df7be 100644 --- a/firestore-tests/rules.test.mjs +++ b/firestore-tests/rules.test.mjs @@ -28,7 +28,13 @@ import { } from "firebase/firestore"; const OWNER = "uid-owner"; + +/** 공유 코드를 받아 감시자로 등록한 가족. 앱에서 「보호자」 자리다. */ const OTHER = "uid-other"; + +/** 코드를 받은 적 없는 제3자. 로그인만 한 계정이 어디까지 닿는지 이 계정으로 잰다(#174). */ +const STRANGER = "uid-stranger"; + const SHARE_CODE = "ABC123"; let testEnv; @@ -88,15 +94,23 @@ beforeEach(async () => { completed: false, sharedCode: "", }); - await setDoc(doc(db, "publicProfiles", OWNER), { - id: OWNER, - name: "느린 사용자", - shareCode: SHARE_CODE, + // 코드를 받은 적 없는 사람에게도 공유 일정이 열리는지 보려면, 감시자가 붙지 않은 + // 다른 코드의 공유 일정이 하나 있어야 한다. + await setDoc(doc(db, "schedules", "other-shared-1"), { + id: "other-shared-1", + userId: OTHER, + title: "남의 공유 일정", + completed: false, + sharedCode: "ZZZ999", }); - await setDoc(doc(db, "publicProfiles", OTHER), { - id: OTHER, - name: "다른 사용자", - shareCode: "ZZZ999", + await setDoc(doc(db, "publicProfiles", OWNER), { id: OWNER, name: "느린 사용자" }); + await setDoc(doc(db, "publicProfiles", OTHER), { id: OTHER, name: "다른 사용자" }); + await setDoc(doc(db, "shareCodes", SHARE_CODE), { userId: OWNER }); + await setDoc(doc(db, "shareCodes", "ZZZ999"), { userId: OTHER }); + // 가족이 코드를 입력해 감시자로 등록한 상태. 이 문서가 공유 읽기 권한의 근거다(#174). + await setDoc(doc(db, "shareCodeWatchers", SHARE_CODE, "tokens", OTHER), { + userId: OTHER, + fcmToken: "token-other", }); await setDoc(doc(db, "notifications", "n-own"), { userId: OWNER, message: "알림" }); await setDoc(doc(db, "notifications", "n-other"), { userId: OTHER, message: "남의 알림" }); @@ -112,6 +126,10 @@ function other() { return testEnv.authenticatedContext(OTHER).firestore(); } +function stranger() { + return testEnv.authenticatedContext(STRANGER).firestore(); +} + function anonymous() { return testEnv.unauthenticatedContext().firestore(); } @@ -143,48 +161,80 @@ describe("users", () => { }); describe("publicProfiles", () => { - it("공유 일정 소유자 이름 조회 질의가 통과한다", async () => { - // UserRepository.getUserNames 가 쓰는 질의다. - const db = other(); - await assertSucceeds(getDocs(query(collection(db, "publicProfiles"), where("id", "in", [OWNER])))); + it("공유 일정 소유자 이름을 문서 하나씩 읽는다", async () => { + // UserRepository.getUserNames 가 쓰는 경로다. 소유자 uid 는 읽을 수 있는 공유 일정에서 나온다. + await assertSucceeds(getDoc(doc(other(), "publicProfiles", OWNER))); }); - it("공유 코드 중복 확인 질의가 통과한다", async () => { - // UserRepository.generateUniqueShareCode 가 쓰는 질의다. - const db = owner(); - await assertSucceeds( - getDocs(query(collection(db, "publicProfiles"), where("shareCode", "==", "NEW123"))), - ); + it("목록으로는 훑지 못한다", async () => { + // 목록을 열어 두면 문서 이름을 몰라도 컬렉션을 통째로 가져갈 수 있다(#174). + const db = other(); + await assertFails(getDocs(collection(db, "publicProfiles"))); + await assertFails(getDocs(query(collection(db, "publicProfiles"), where("id", "in", [OWNER])))); }); it("본인 프로필만 쓴다", async () => { const db = owner(); - await assertSucceeds( - setDoc(doc(db, "publicProfiles", OWNER), { id: OWNER, name: "새 이름", shareCode: SHARE_CODE }), - ); - await assertFails( - setDoc(doc(db, "publicProfiles", OTHER), { id: OTHER, name: "가로채기", shareCode: "ZZZ999" }), - ); + await assertSucceeds(setDoc(doc(db, "publicProfiles", OWNER), { id: OWNER, name: "새 이름" })); + await assertFails(setDoc(doc(db, "publicProfiles", OTHER), { id: OTHER, name: "가로채기" })); }); - it("이름·공유 코드 밖의 값은 넣지 못한다", async () => { + it("이름 밖의 값은 넣지 못한다", async () => { // 이메일이나 토큰이 공개 프로필로 새어 나가지 않게 필드를 묶는다. const db = owner(); await assertFails( setDoc(doc(db, "publicProfiles", OWNER), { id: OWNER, name: "느린 사용자", - shareCode: SHARE_CODE, email: "owner@example.com", }), ); }); + it("공유 코드는 공개 프로필에 담지 못한다", async () => { + // 코드는 사람을 찾는 열쇠라 이름 옆에 둘 값이 아니다(#174). + await assertFails( + setDoc(doc(owner(), "publicProfiles", OWNER), { + id: OWNER, + name: "느린 사용자", + shareCode: SHARE_CODE, + }), + ); + }); + it("로그인하지 않으면 읽지 못한다", async () => { await assertFails(getDoc(doc(anonymous(), "publicProfiles", OWNER))); }); }); +describe("shareCodes", () => { + it("비어 있는 코드는 본인 것으로 만들 수 있다", async () => { + // UserRepository.generateUniqueShareCode 가 쓰는 경로다. + await assertSucceeds(setDoc(doc(owner(), "shareCodes", "NEW123"), { userId: OWNER })); + }); + + it("이미 임자가 있는 코드는 가져가지 못한다", async () => { + // 중복 확인과 저장이 한 번의 만들기로 합쳐져 그 사이의 틈이 없다(#174). + await assertFails(setDoc(doc(other(), "shareCodes", SHARE_CODE), { userId: OTHER })); + }); + + it("남의 이름으로 코드를 만들지 못한다", async () => { + await assertFails(setDoc(doc(other(), "shareCodes", "NEW456"), { userId: OWNER })); + }); + + it("코드 등록부는 읽지 못한다", async () => { + // 코드 목록은 그 자체가 열쇠 꾸러미다. 누구에게도 열지 않는다. + const db = other(); + await assertFails(getDoc(doc(db, "shareCodes", SHARE_CODE))); + await assertFails(getDocs(collection(db, "shareCodes"))); + }); + + it("본인 코드만 반납한다", async () => { + await assertSucceeds(deleteDoc(doc(owner(), "shareCodes", SHARE_CODE))); + await assertFails(deleteDoc(doc(other(), "shareCodes", SHARE_CODE))); + }); +}); + describe("schedules", () => { it("본인 일정을 날짜로 조회하고 만들고 지운다", async () => { const db = owner(); @@ -205,7 +255,7 @@ describe("schedules", () => { ); }); - it("공유 코드로 조회한 일정은 읽고 완료만 바꾼다", async () => { + it("코드를 등록한 가족은 공유 일정을 조회하고 완료만 바꾼다", async () => { const db = other(); await assertSucceeds(getDocs(query(collection(db, "schedules"), where("sharedCode", "==", SHARE_CODE)))); await assertSucceeds(updateDoc(doc(db, "schedules", "shared-1"), { completed: true })); @@ -222,6 +272,49 @@ describe("schedules", () => { it("공유 일정이라도 제목은 바꾸지 못한다", async () => { await assertFails(updateDoc(doc(other(), "schedules", "shared-1"), { title: "바뀐 제목" })); }); + + it("코드를 받지 않은 제3자는 공유 일정을 읽지 못한다", async () => { + // 「공유로 표시됐는가」 만 보면 로그인만 한 계정에게도 열린다. 병원·복약 같은 제목이 + // 그대로 나가므로 관계를 확인한다(#174). + const db = stranger(); + await assertFails(getDoc(doc(db, "schedules", "shared-1"))); + await assertFails(getDocs(query(collection(db, "schedules"), where("sharedCode", "==", SHARE_CODE)))); + }); + + it("코드를 받지 않은 제3자는 완료 상태를 바꾸지 못한다", async () => { + // 읽기보다 이쪽이 더 아프다. 남이 완료로 바꿔 두면 어르신이 약을 이미 먹은 줄로 안다. + const db = stranger(); + await assertFails(updateDoc(doc(db, "schedules", "shared-1"), { completed: true })); + await assertFails(updateDoc(doc(db, "schedules", "shared-1"), { completedDates: ["2026-09-06"] })); + }); + + it("공유로 표시된 일정을 통째로 훑지 못한다", async () => { + // 코드를 하나 받았다고 다른 집 일정까지 딸려 오면 안 된다. + const db = other(); + await assertFails(getDocs(query(collection(db, "schedules"), where("sharedCode", ">", "")))); + await assertFails(getDocs(collection(db, "schedules"))); + }); + + it("한 코드에 공유 일정이 많아도 목록 조회가 통과한다", async () => { + // 공유 읽기 규칙이 문서마다 감시자 등록을 확인한다. 규칙의 문서 접근 횟수에는 한도가 + // 있으므로, 일정이 늘어도 조회가 막히지 않는지 실제로 재 둔다(#174). + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + for (let i = 0; i < 40; i += 1) { + await setDoc(doc(db, "schedules", `bulk-${i}`), { + id: `bulk-${i}`, + userId: OWNER, + title: `일정 ${i}`, + completed: false, + sharedCode: SHARE_CODE, + }); + } + }); + const snapshot = await assertSucceeds( + getDocs(query(collection(other(), "schedules"), where("sharedCode", "==", SHARE_CODE))), + ); + assert.equal(snapshot.size, 41); + }); }); describe("notifications", () => { @@ -242,9 +335,9 @@ describe("notifications", () => { describe("shareCodeWatchers", () => { it("자기 토큰만 등록하고 지운다", async () => { // UserRepository.registerShareCodeWatcher / unregisterShareCodeWatcher 가 쓰는 경로다. - const db = other(); - const ref = doc(db, "shareCodeWatchers", SHARE_CODE, "tokens", OTHER); - await assertSucceeds(setDoc(ref, { fcmToken: "token-other" })); + const db = stranger(); + const ref = doc(db, "shareCodeWatchers", SHARE_CODE, "tokens", STRANGER); + await assertSucceeds(setDoc(ref, { userId: STRANGER, fcmToken: "token-stranger" })); await assertSucceeds(deleteDoc(ref)); }); @@ -252,6 +345,13 @@ describe("shareCodeWatchers", () => { const ref = doc(other(), "shareCodeWatchers", SHARE_CODE, "tokens", OWNER); await assertFails(setDoc(ref, { fcmToken: "가짜" })); }); + + it("FCM 토큰이 없어도 등록된다", async () => { + // 이 문서가 읽기 권한의 근거라, 토큰을 못 받았다고 만들지 못하면 가족 일정이 통째로 + // 안 보인다. 토큰은 알림을 보내기 위한 값일 뿐이다(#174). + const ref = doc(stranger(), "shareCodeWatchers", SHARE_CODE, "tokens", STRANGER); + await assertSucceeds(setDoc(ref, { userId: STRANGER })); + }); }); describe("scheduleRecommendations", () => { diff --git a/firestore.rules b/firestore.rules index e9aebb3a..168ab5be 100644 --- a/firestore.rules +++ b/firestore.rules @@ -9,21 +9,51 @@ service cloud.firestore { return isSignedIn() && request.auth.uid == userId; } + // 이 사람이 그 공유 코드의 감시자로 등록돼 있는가. + // + // 「로그인했는가」 만 보면 공유로 표시된 일정은 관계 없는 계정에게도 열린다. 공유 코드를 + // 실제로 받아 등록한 사람인지까지 봐야 범위가 가족으로 좁아진다(#174). + function watchesShareCode(shareCode) { + return isSignedIn() && exists( + /databases/$(database)/documents/shareCodeWatchers/$(shareCode)/tokens/$(request.auth.uid) + ); + } + // 사용자 문서. 이메일과 FCM 토큰이 함께 있어 본인만 읽고 쓴다. - // 다른 사람에게 보여야 하는 이름·공유 코드는 publicProfiles 에 따로 둔다(#93). + // 다른 사람에게 보여야 하는 이름은 publicProfiles 에 따로 둔다(#93). match /users/{userId} { allow read, write: if isSelf(userId); } - // 공개 프로필. 공유 일정 소유자의 이름을 보여 주고 공유 코드로 사람을 찾는 데 쓴다. - // 읽기는 로그인한 사용자 전체, 쓰기는 본인 문서만이고 담을 수 있는 필드도 셋으로 묶는다. + // 공개 프로필. 공유 일정 소유자의 이름을 보여 주는 데만 쓴다. + // + // get 만 열고 list 는 막는다. 목록을 열어 두면 문서 이름을 몰라도 컬렉션을 통째로 훑을 수 + // 있어 「아는 사람만 본다」 라는 전제가 성립하지 않는다. 소유자 uid 는 이미 읽을 수 있는 + // 공유 일정에서만 나오므로 get 하나로 충분하다(#174). + // + // 공유 코드는 여기에 두지 않는다. 사람을 찾는 열쇠라 이름 옆에 둘 값이 아니다. match /publicProfiles/{userId} { - allow read: if isSignedIn(); + allow get: if isSignedIn(); + allow list: if false; allow write: if isSelf(userId) - && request.resource.data.keys().hasOnly(['id', 'name', 'shareCode']) + && request.resource.data.keys().hasOnly(['id', 'name']) && request.resource.data.id == userId; } + // 공유 코드 등록부. 코드 자체가 문서 이름이다. + // + // 중복 확인을 질의로 하면 컬렉션을 훑을 권한이 필요하고, 확인과 저장 사이에 같은 코드를 + // 두 사람이 가져가는 틈도 남는다. 문서 만들기 한 번으로 두 문제가 함께 사라진다. + // 읽기는 아무에게도 열지 않는다. 코드 목록은 그 자체가 사람을 찾는 열쇠 꾸러미다(#174). + match /shareCodes/{shareCode} { + allow read: if false; + allow create: if isSignedIn() + && request.resource.data.keys().hasOnly(['userId']) + && request.resource.data.userId == request.auth.uid; + allow update: if false; + allow delete: if isSignedIn() && resource.data.userId == request.auth.uid; + } + // 일정 match /schedules/{scheduleId} { // 본인이 만든 일정 @@ -31,16 +61,16 @@ service cloud.firestore { (request.auth.uid == resource.data.userId || request.auth.uid == request.resource.data.userId); - // 공유 일정은 sharedCode 가 비어 있지 않으면 읽을 수 있다. - allow read: if isSignedIn() && - resource.data.sharedCode != null && - resource.data.sharedCode != ""; + // 공유 일정은 그 코드의 감시자로 등록한 사람만 읽는다. + allow read: if resource.data.sharedCode is string && + resource.data.sharedCode != "" && + watchesShareCode(resource.data.sharedCode); // 공유 일정의 완료 상태만 바꿀 수 있다. 반복 일정은 회차마다 따로 남으므로 // completedDates 도 같은 범위에 든다(#130). - allow update: if isSignedIn() && - resource.data.sharedCode != null && + allow update: if resource.data.sharedCode is string && resource.data.sharedCode != "" && + watchesShareCode(resource.data.sharedCode) && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['completed', 'completedDates', 'updatedAt']); } @@ -63,7 +93,9 @@ service cloud.firestore { request.auth.uid in resource.data.memberIds); } - // 공유코드 감시자 (FCM 토큰 저장용). 서버(Cloud Functions)가 관리자 권한으로 읽어 알림을 보낸다. + // 공유코드 감시자. 이 문서가 있다는 것이 곧 「그 코드를 받은 사람」 이라는 표시라, + // 공유 일정 읽기 권한의 근거가 된다. 서버(Cloud Functions)가 관리자 권한으로 읽어 + // 알림을 보낸다. match /shareCodeWatchers/{shareCode}/tokens/{userId} { allow read, write: if isSelf(userId); }