SecureFlagCoverageTest.kt

5.5 KB · raw

1package com.peachpasswords.security23import javax.xml.parsers.DocumentBuilderFactory4import org.junit.Assert.assertFalse5import org.junit.Assert.assertTrue6import org.junit.Test7import org.w3c.dom.Element89class SecureFlagCoverageTest {1011    // Compose screens that surface secret material. They live in the MainActivity12    // window, where FLAG_SECURE is already set release-wide, but they must also13    // enforce it during the lifetime of the screen in debug builds — which is14    // what SecureFlagEffect() does. (H-05.)15    private val secretScreens = listOf(16        "app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt",17        "app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt",18        "app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt",19        "app/src/main/java/com/peachpasswords/ui/vault/ExportScreen.kt",20        "app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt",21        "app/src/main/java/com/peachpasswords/ui/recovery/RecoveryWordEntryScreen.kt",22        "app/src/main/java/com/peachpasswords/ui/codex/CodexGenerateScreen.kt",23    )2425    // Activities whose own window is separate from MainActivity (overlay / system26    // API surfaces). They do NOT inherit FLAG_SECURE from MainActivity, so each27    // one must set it directly in its own onCreate. (H-05.)28    private val secretActivities = listOf(29        "com.peachpasswords.autofill.AutofillEntryPickerActivity",30        "com.peachpasswords.passkeys.PasskeyCredentialSelectionActivity",31    )3233    @Test fun secret_screens_keep_flag_secure_coverage() {34        secretScreens.forEach { relativePath ->35            val file = java.io.File(relativePath).takeIf { it.exists() }36                ?: java.io.File("../$relativePath")37            val source = file.readText()38            assertTrue(39                "$relativePath must protect screenshots while visible",40                source.contains("SecureFlagEffect()") ||41                    source.contains("WindowManager.LayoutParams.FLAG_SECURE"),42            )43        }44    }4546    @Test fun secret_bearing_activities_set_flag_secure() {47        // Every secret-bearing activity listed in the manifest must set48        // FLAG_SECURE itself — MainActivity's release-wide set does not reach49        // these windows. (H-05.)50        val manifestActivities = parseActivityNames(51            "app/src/main/AndroidManifest.xml",52        )53        secretActivities.forEach { fqcn ->54            val shortName = fqcn.substringAfterLast('.')55            assertTrue(56                "$fqcn must be declared in the manifest",57                manifestActivities.any { it == fqcn || it.endsWith(".$shortName") },58            )59            val relativePath = fqcnToSourcePath(fqcn)60            val file = java.io.File(relativePath).takeIf { it.exists() }61                ?: java.io.File("../$relativePath")62            val source = file.readText()63            assertTrue(64                "$fqcn must delegate secure ownership " +65                    "(separate window from MainActivity)",66                source.contains("SecureWindowPolicy"),67            )68        }69    }7071    @Test fun all_window_policy_transitions_are_centralized() {72        val helper = java.io.File(73            "app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt",74        ).takeIf { it.exists() } ?: java.io.File(75            "../app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt",76        )77        val source = helper.readText()78        assertTrue(source.contains("installRelease"))79        assertTrue(source.contains("leases"))80        assertTrue(source.contains("releaseOwned"))81        assertTrue(source.contains("clearFlags"))82        // MainActivity and secret activities delegate to the owner; they must83        // not carry a second ad-hoc clear/set lifecycle that can drift.84        listOf(85            "app/src/main/java/com/peachpasswords/MainActivity.kt",86            "app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt",87            "app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt",88        ).forEach { relativePath ->89            val file = java.io.File(relativePath).takeIf { it.exists() }90                ?: java.io.File("../$relativePath")91            val activitySource = file.readText()92            assertTrue(93                "$relativePath must delegate secure policy ownership",94                activitySource.contains("SecureWindowPolicy"),95            )96            assertFalse(97                "$relativePath must not clear FLAG_SECURE directly",98                activitySource.contains("clearFlags("),99            )100        }101    }102103    private fun parseActivityNames(relativePath: String): Set<String> {104        val file = java.io.File(relativePath).takeIf { it.exists() }105            ?: java.io.File("../$relativePath")106        val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()107            .parse(file)108        val activities = doc.getElementsByTagName("activity")109        return (0 until activities.length)110            .mapNotNull { (activities.item(it) as? Element)?.getAttribute("android:name") }111            .toSet()112    }113114    // Activities in the manifest are declared as ".Foo" (relative to the115    // application package). The activity source lives at the matching package116    // path under app/src/main/java.117    private fun fqcnToSourcePath(fqcn: String): String {118        val pkg = fqcn.substringBeforeLast('.')119        val cls = fqcn.substringAfterLast('.')120        return "app/src/main/java/" + pkg.replace('.', '/') + "/$cls.kt"121    }122}