SecureFlagCoverageTest.kt

5.6 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("setUserSecure"))79        assertTrue(source.contains("acquire"))80        assertTrue(source.contains("release"))81        assertTrue(source.contains("userChoiceApplied"))82        assertTrue(source.contains("leases"))83        assertTrue(source.contains("clearFlags"))84        // MainActivity and secret activities delegate to the owner; they must85        // not carry a second ad-hoc clear/set lifecycle that can drift.86        listOf(87            "app/src/main/java/com/peachpasswords/MainActivity.kt",88            "app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt",89            "app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt",90        ).forEach { relativePath ->91            val file = java.io.File(relativePath).takeIf { it.exists() }92                ?: java.io.File("../$relativePath")93            val activitySource = file.readText()94            assertTrue(95                "$relativePath must delegate secure policy ownership",96                activitySource.contains("SecureWindowPolicy"),97            )98            assertFalse(99                "$relativePath must not clear FLAG_SECURE directly",100                activitySource.contains("clearFlags("),101            )102        }103    }104105    private fun parseActivityNames(relativePath: String): Set<String> {106        val file = java.io.File(relativePath).takeIf { it.exists() }107            ?: java.io.File("../$relativePath")108        val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()109            .parse(file)110        val activities = doc.getElementsByTagName("activity")111        return (0 until activities.length)112            .mapNotNull { (activities.item(it) as? Element)?.getAttribute("android:name") }113            .toSet()114    }115116    // Activities in the manifest are declared as ".Foo" (relative to the117    // application package). The activity source lives at the matching package118    // path under app/src/main/java.119    private fun fqcnToSourcePath(fqcn: String): String {120        val pkg = fqcn.substringBeforeLast('.')121        val cls = fqcn.substringAfterLast('.')122        return "app/src/main/java/" + pkg.replace('.', '/') + "/$cls.kt"123    }124}