package com.peachpasswords.security import javax.xml.parsers.DocumentBuilderFactory import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.w3c.dom.Element class SecureFlagCoverageTest { // Compose screens that surface secret material. They live in the MainActivity // window, where FLAG_SECURE is already set release-wide, but they must also // enforce it during the lifetime of the screen in debug builds — which is // what SecureFlagEffect() does. (H-05.) private val secretScreens = listOf( "app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt", "app/src/main/java/com/peachpasswords/ui/vault/EntryEditScreen.kt", "app/src/main/java/com/peachpasswords/ui/vault/NotesModeScreen.kt", "app/src/main/java/com/peachpasswords/ui/vault/ExportScreen.kt", "app/src/main/java/com/peachpasswords/ui/vault/RecoveryWordsScreen.kt", "app/src/main/java/com/peachpasswords/ui/recovery/RecoveryWordEntryScreen.kt", "app/src/main/java/com/peachpasswords/ui/codex/CodexGenerateScreen.kt", ) // Activities whose own window is separate from MainActivity (overlay / system // API surfaces). They do NOT inherit FLAG_SECURE from MainActivity, so each // one must set it directly in its own onCreate. (H-05.) private val secretActivities = listOf( "com.peachpasswords.autofill.AutofillEntryPickerActivity", "com.peachpasswords.passkeys.PasskeyCredentialSelectionActivity", "com.peachpasswords.credentials.CredentialProviderUnlockActivity", ) @Test fun secret_screens_keep_flag_secure_coverage() { secretScreens.forEach { relativePath -> val file = java.io.File(relativePath).takeIf { it.exists() } ?: java.io.File("../$relativePath") val source = file.readText() assertTrue( "$relativePath must protect screenshots while visible", source.contains("SecureFlagEffect()") || source.contains("WindowManager.LayoutParams.FLAG_SECURE"), ) } } @Test fun secret_bearing_activities_set_flag_secure() { // Every secret-bearing activity listed in the manifest must set // FLAG_SECURE itself — MainActivity's release-wide set does not reach // these windows. (H-05.) val manifestActivities = parseActivityNames( "app/src/main/AndroidManifest.xml", ) secretActivities.forEach { fqcn -> val shortName = fqcn.substringAfterLast('.') assertTrue( "$fqcn must be declared in the manifest", manifestActivities.any { it == fqcn || it.endsWith(".$shortName") }, ) val relativePath = fqcnToSourcePath(fqcn) val file = java.io.File(relativePath).takeIf { it.exists() } ?: java.io.File("../$relativePath") val source = file.readText() assertTrue( "$fqcn must delegate secure ownership " + "(separate window from MainActivity)", source.contains("SecureWindowPolicy"), ) } } @Test fun all_window_policy_transitions_are_centralized() { val helper = java.io.File( "app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt", ).takeIf { it.exists() } ?: java.io.File( "../app/src/main/java/com/peachpasswords/ui/components/SecureWindowPolicy.kt", ) val source = helper.readText() assertTrue(source.contains("setUserSecure")) assertTrue(source.contains("acquire")) assertTrue(source.contains("release")) assertTrue(source.contains("userChoiceApplied")) assertTrue(source.contains("leases")) assertTrue(source.contains("clearFlags")) // MainActivity and secret activities delegate to the owner; they must // not carry a second ad-hoc clear/set lifecycle that can drift. listOf( "app/src/main/java/com/peachpasswords/MainActivity.kt", "app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt", "app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt", "app/src/main/java/com/peachpasswords/credentials/CredentialProviderUnlockActivity.kt", ).forEach { relativePath -> val file = java.io.File(relativePath).takeIf { it.exists() } ?: java.io.File("../$relativePath") val activitySource = file.readText() assertTrue( "$relativePath must delegate secure policy ownership", activitySource.contains("SecureWindowPolicy"), ) assertFalse( "$relativePath must not clear FLAG_SECURE directly", activitySource.contains("clearFlags("), ) } } private fun parseActivityNames(relativePath: String): Set { val file = java.io.File(relativePath).takeIf { it.exists() } ?: java.io.File("../$relativePath") val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(file) val activities = doc.getElementsByTagName("activity") return (0 until activities.length) .mapNotNull { (activities.item(it) as? Element)?.getAttribute("android:name") } .toSet() } // Activities in the manifest are declared as ".Foo" (relative to the // application package). The activity source lives at the matching package // path under app/src/main/java. private fun fqcnToSourcePath(fqcn: String): String { val pkg = fqcn.substringBeforeLast('.') val cls = fqcn.substringAfterLast('.') return "app/src/main/java/" + pkg.replace('.', '/') + "/$cls.kt" } }