Files
crossdesk/apps/ios/tests/privacy_controls_test.py
T

174 lines
8.2 KiB
Python

"""Run the production privacy preferences and preview file store on macOS."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
@unittest.skipUnless(sys.platform == "darwin", "requires the Apple Swift runtime")
class PrivacyControlsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory(prefix="crossdesk-privacy-tests-")
cls.addClassCleanup(cls.temp.cleanup)
root = Path(cls.temp.name)
harness = root / "PrivacyTests.swift"
harness.write_text(r'''
import Foundation
func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError(message) }
}
func wait(_ semaphore: DispatchSemaphore) {
expect(semaphore.wait(timeout: .now() + 10) == .success, "Operation timed out")
}
@main
enum PrivacyTests {
static func main() throws {
let test = CommandLine.arguments[1]
if test == "consent" || test == "upgrade" {
let suite = "crossdesk.privacy.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
if test == "upgrade" {
// Existing identity/history must not be interpreted as consent.
defaults.set("test-identity", forKey: "crossdesk.mobile.identity")
defaults.set(Data("legacy history".utf8),
forKey: "crossdesk.mobile.recent-connections.v1")
}
let preferences = PrivacyPreferences(defaults: defaults)
expect(PrivacyNoticeState(hasNetworkConsent: preferences.hasNetworkConsent).isVisible,
"New/legacy install must show the policy")
expect(!preferences.hasNetworkConsent, "Must not start networking")
expect(!preferences.savesThumbnails, "Saving must default to off")
preferences.setSavesThumbnails(true)
expect(!preferences.savesThumbnails, "Cannot enable captures without consent")
preferences.setNetworkConsent(false)
let declined = PrivacyPreferences(defaults: defaults)
expect(!declined.hasNetworkConsent, "Decline must not allow networking")
expect(PrivacyNoticeState(hasNetworkConsent: declined.hasNetworkConsent).isVisible,
"Relaunch after refusal must show the policy again")
declined.setNetworkConsent(true)
expect(!declined.savesThumbnails, "Network consent is not screenshot consent")
declined.setSavesThumbnails(true)
let relaunched = PrivacyPreferences(defaults: defaults)
expect(relaunched.hasNetworkConsent && relaunched.savesThumbnails,
"Explicit choices must survive relaunch")
expect(!PrivacyNoticeState(hasNetworkConsent: relaunched.hasNetworkConsent).isVisible,
"Relaunch after agreement must not prompt again")
relaunched.setNetworkConsent(false)
expect(!relaunched.hasNetworkConsent && !relaunched.savesThumbnails,
"Withdrawal revokes both permissions")
expect(PrivacyNoticeState(hasNetworkConsent: relaunched.hasNetworkConsent).isVisible,
"Relaunch after withdrawal must show the policy")
relaunched.setNetworkConsent(true)
expect(!relaunched.savesThumbnails, "Re-consent must not re-enable screenshots")
} else if test == "notice_lifecycle" {
var notice = PrivacyNoticeState(hasNetworkConsent: false)
expect(notice.isVisible, "No consent must prompt on cold launch")
notice.didBecomeActive(hasNetworkConsent: false)
notice.dismiss()
notice.didBecomeActive(hasNetworkConsent: false)
expect(!notice.isVisible, "Refusal must not immediately reopen the notice")
notice.didBecomeActive(hasNetworkConsent: false)
expect(!notice.isVisible, "Transient inactivity or duplicate active callbacks are not another visit")
for _ in 0..<3 {
notice.didEnterBackground()
notice.didBecomeActive(hasNetworkConsent: false)
expect(notice.isVisible, "Every background/foreground round trip must prompt without consent")
notice.dismiss()
}
notice.show()
expect(notice.isVisible, "The status button can still open the policy manually")
notice.dismiss()
notice.didEnterBackground()
notice.didBecomeActive(hasNetworkConsent: true)
expect(!notice.isVisible, "Granting consent stops automatic prompts")
notice.didEnterBackground()
notice.didBecomeActive(hasNetworkConsent: false)
expect(notice.isVisible, "Withdrawal restores the next-visit prompt")
} else {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("crossdesk-thumbnails-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: directory) }
let store = ThumbnailFileStore(directory: directory)
let started = DispatchSemaphore(value: 0)
let finishCapture = DispatchSemaphore(value: 0)
let cleared = DispatchSemaphore(value: 0)
store.save(fileName: "remote.jpg", makeData: {
started.signal()
wait(finishCapture)
return Data("sensitive frame".utf8)
}) { fileName in
expect(fileName == "remote.jpg", "Capture must exercise the real write path")
}
wait(started)
if test == "clear" {
// Withdrawal/clear arrives while image conversion is still running.
store.removeAll { success in
expect(success, "Clear must succeed")
cleared.signal()
}
} else {
// Removing a recent connection must also win over its pending capture.
store.remove(fileName: "remote.jpg")
store.save(fileName: "barrier.jpg", makeData: { Data([1]) }) { _ in
cleared.signal()
}
}
finishCapture.signal()
wait(cleared)
expect(!FileManager.default.fileExists(atPath: store.url(for: "remote.jpg").path),
"A pending write must not resurrect a deleted preview")
// A later explicitly permitted capture can still be saved.
let saved = DispatchSemaphore(value: 0)
store.save(fileName: "new.jpg", makeData: { Data("new frame".utf8) }) { name in
expect(name == "new.jpg", "New captures must work after clearing")
saved.signal()
}
wait(saved)
let data = try Data(contentsOf: store.url(for: "new.jpg"))
expect(data == Data("new frame".utf8), "Saved preview must be intact")
let values = try directory.resourceValues(forKeys: [.isExcludedFromBackupKey])
expect(values.isExcludedFromBackup == true, "Previews must be excluded from backup")
}
}
}
''')
source = Path(__file__).resolve().parents[1] / "CrossDeskMobile"
cls.binary = root / "privacy-tests"
subprocess.run([
"xcrun", "swiftc", "-swift-version", "5",
str(source / "PrivacyPreferences.swift"),
str(source / "ThumbnailFileStore.swift"), str(harness),
"-o", str(cls.binary),
], check=True, capture_output=True, text=True)
def run_case(self, case):
result = subprocess.run([str(self.binary), case], capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_consent_persistence_and_withdrawal(self):
self.run_case("consent")
def test_upgrade_requires_fresh_choices(self):
self.run_case("upgrade")
def test_notice_on_each_unconsented_visit(self):
self.run_case("notice_lifecycle")
def test_clear_wins_over_in_flight_capture(self):
self.run_case("clear")
def test_remove_connection_wins_over_in_flight_capture(self):
self.run_case("remove")
if __name__ == "__main__":
unittest.main()