diff --git a/tests/README.md b/tests/README.md index 27f9932..ca987ed 100644 --- a/tests/README.md +++ b/tests/README.md @@ -246,6 +246,12 @@ Linux, and macOS with Python 3.13 and PySide6 6.8.3. The publication workflow re that job before PyPI publication. Opt-in tests and packaged/manual smoke checks remain separate; local Windows results do not establish the other CI targets. +The settings harness compares canonical paths on every access so macOS temporary-directory +aliases and Windows short names do not look like sandbox escapes. Its isolation regressions +cover an aliased root and rejection of paths outside that root. The QR responsiveness test +queues unrelated work after generation starts and requires delivery before completion; +it does not assume a platform-specific order between zero-delay Qt timers. + The Home/Log workflow regressions exercise debounced typing, explicit submit/clear, navigation catch-up, focus-scoped Find/edit shortcuts, and live log filtering. These are real Qt tests in `test_qt_interactions.py` and `test_ui_behavior.py`; no production log source or profile network is started. The two optional pause regressions diff --git a/tests/support.py b/tests/support.py index 74c2c93..35cc2a6 100644 --- a/tests/support.py +++ b/tests/support.py @@ -108,7 +108,9 @@ def _initializeSettingsSandbox(): global _settingsDirectory if _settingsDirectory is not None: - return Path(_settingsDirectory.name) + # TemporaryDirectory may retain a macOS /var alias or Windows short + # path. Match the canonical root used on initialization and by Qt. + return Path(_settingsDirectory.name).resolve() _settingsDirectory = tempfile.TemporaryDirectory( prefix='furious-tests-settings-root-' diff --git a/tests/test_isolation_and_navigation.py b/tests/test_isolation_and_navigation.py index 19b1440..c45f4ca 100644 --- a/tests/test_isolation_and_navigation.py +++ b/tests/test_isolation_and_navigation.py @@ -26,6 +26,9 @@ from PySide6.QtTest import QTest from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget from pathlib import Path +from unittest import mock + +import tests.support as SupportModule from tests.support import ( application, @@ -58,7 +61,9 @@ class TestHarnessIsolationTest(unittest.TestCase): assertIsolatedSettings(outer) - self.assertIn(settingsSandboxPath(), Path(outer.fileName()).parents) + self.assertIn( + settingsSandboxPath(), Path(outer.fileName()).resolve().parents + ) with isolatedSettings() as inner: innerIdentity = (app.organizationName(), app.applicationName()) @@ -87,7 +92,35 @@ class TestHarnessIsolationTest(unittest.TestCase): assertIsolatedSettings(settings) - self.assertIn(settingsSandboxPath(), Path(settings.fileName()).parents) + self.assertIn( + settingsSandboxPath(), Path(settings.fileName()).resolve().parents + ) + + def testExistingSandboxRootUsesTheSameCanonicalPath(self): + """Normalize reused temporary-directory aliases before containment checks.""" + root = settingsSandboxPath().resolve() + alias = root / '..' / root.name + + # TemporaryDirectory.name can retain an alias even though QSettings + # returns the canonical path (macOS /var or Windows short user names). + with mock.patch.object(SupportModule._settingsDirectory, 'name', str(alias)): + self.assertEqual(settingsSandboxPath(), root) + + with isolatedSettings() as settings: + assertIsolatedSettings(settings) + settings.setValue('alias-fixture', 'isolated') + settings.sync() + self.assertEqual(settings.value('alias-fixture'), 'isolated') + + def testSettingsOutsideSandboxAreStillRejected(self): + """Reject a sibling path even when its name shares the sandbox prefix.""" + root = settingsSandboxPath().resolve() + outside = root.with_name(root.name + '-outside') / 'settings.ini' + settings = QtCore.QSettings(str(outside), QtCore.QSettings.Format.IniFormat) + + # Constructing this object does not write; rejection must precede writes. + with self.assertRaisesRegex(AssertionError, 'escaped sandbox'): + assertIsolatedSettings(settings) class NavigationBehaviorTest(unittest.TestCase): diff --git a/tests/test_qr_export_scalability.py b/tests/test_qr_export_scalability.py index 0dc4b71..86a79f3 100644 --- a/tests/test_qr_export_scalability.py +++ b/tests/test_qr_export_scalability.py @@ -168,7 +168,7 @@ class QRCodeExportScalabilityTest(unittest.TestCase): self.assertEqual(appendExportItem.call_count, expected) def testGenerationYieldsToAnUnrelatedQtEvent(self): - """Deliver unrelated work after one attempt and before batch completion.""" + """Deliver unrelated work during generation, before batch completion.""" profiles = self.profiles(6) attempts = [] marker = [] @@ -178,6 +178,12 @@ class QRCodeExportScalabilityTest(unittest.TestCase): """Record one exported snapshot.""" attempts.append(profile.itemRemark) + if len(attempts) == 1: + # Zero-timer ordering is platform-dependent. Queue the marker + # only once generation has started, then require it to run + # before all profiles finish rather than after exactly one. + QtCore.QTimer.singleShot(0, lambda: marker.append(len(attempts))) + return f'socks://{len(attempts)}.example:1080#Fixture' with mock.patch( @@ -191,13 +197,14 @@ class QRCodeExportScalabilityTest(unittest.TestCase): side_effect=self.qrImage, ): result = window.startExportByIndex(range(len(profiles))) - QtCore.QTimer.singleShot(0, lambda: marker.append(len(attempts))) self.assertIs(result, window) self.assertEqual(attempts, []) self.assertTrue(window.isVisible()) self.assertTrue(waitFor(lambda: bool(marker))) - self.assertEqual(marker, [1]) + self.assertEqual(len(marker), 1) + self.assertGreaterEqual(marker[0], 1) + self.assertLess(marker[0], len(profiles)) self.assertTrue(waitFor(lambda: not window.isExporting())) self.assertEqual(len(attempts), len(profiles))