SecureFlagCoverageTest.kt

5.8 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        "com.peachpasswords.credentials.CredentialProviderUnlockActivity",32    )3334    @Test fun secret_screens_keep_flag_secure_coverage() {35        secretScreens.forEach { relativePath ->36            val file = java.io.File(relativePath).takeIf { it.exists() }37                ?: java.io.File("../$relativePath")38            val source = file.readText()39            assertTrue(40                "$relativePath must protect screenshots while visible",41                source.contains("SecureFlagEffect()") ||42                    source.contains("WindowManager.LayoutParams.FLAG_SECURE"),43            )44        }45    }4647    @Test fun secret_bearing_activities_set_flag_secure() {48        // Every secret-bearing activity listed in the manifest must set49        // FLAG_SECURE itself — MainActivity's release-wide set does not reach50        // these windows. (H-05.)51        val manifestActivities = parseActivityNames(52            "app/src/main/AndroidManifest.xml",53        )54        secretActivities.forEach { fqcn ->55            val shortName = fqcn.substringAfterLast('.')56            assertTrue(57                "$fqcn must be declared in the manifest",58                manifestActivities.any { it == fqcn || it.endsWith(".$shortName") },59            )60            val relativePath = fqcnToSourcePath(fqcn)61            val file = java.io.File(relativePath).takeIf { it.exists() }62                ?: java.io.File("../$relativePath")63            val source = file.readText()64            assertTrue(65                "$fqcn must delegate secure ownership " +66                    "(separate window from MainActivity)",67                source.contains("SecureWindowPolicy"),68            )69        }70    }7172    @Test fun all_window_policy_transitions_are_centralized() {73        val helper = java.io.File(74            "app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt",75        ).takeIf { it.exists() } ?: java.io.File(76            "../app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt",77        )78        val source = helper.readText()79        assertTrue(source.contains("setUserSecure"))80        assertTrue(source.contains("acquire"))81        assertTrue(source.contains("release"))82        assertTrue(source.contains("userChoiceApplied"))83        assertTrue(source.contains("leases"))84        assertTrue(source.contains("clearFlags"))85        // MainActivity and secret activities delegate to the owner; they must86        // not carry a second ad-hoc clear/set lifecycle that can drift.87        listOf(88            "app/src/main/java/com/peachpasswords/MainActivity.kt",89            "app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt",90            "app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt",91            "app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt",92        ).forEach { relativePath ->93            val file = java.io.File(relativePath).takeIf { it.exists() }94                ?: java.io.File("../$relativePath")95            val activitySource = file.readText()96            assertTrue(97                "$relativePath must delegate secure policy ownership",98                activitySource.contains("SecureWindowPolicy"),99            )100            assertFalse(101                "$relativePath must not clear FLAG_SECURE directly",102                activitySource.contains("clearFlags("),103            )104        }105    }106107    private fun parseActivityNames(relativePath: String): Set<String> {108        val file = java.io.File(relativePath).takeIf { it.exists() }109            ?: java.io.File("../$relativePath")110        val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()111            .parse(file)112        val activities = doc.getElementsByTagName("activity")113        return (0 until activities.length)114            .mapNotNull { (activities.item(it) as? Element)?.getAttribute("android:name") }115            .toSet()116    }117118    // Activities in the manifest are declared as ".Foo" (relative to the119    // application package). The activity source lives at the matching package120    // path under app/src/main/java.121    private fun fqcnToSourcePath(fqcn: String): String {122        val pkg = fqcn.substringBeforeLast('.')123        val cls = fqcn.substringAfterLast('.')124        return "app/src/main/java/" + pkg.replace('.', '/') + "/$cls.kt"125    }126}