From 44c5db7685d2fd17ef0e4a0a796528d97c86e17c Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 26 Jun 2026 17:32:57 +0100 Subject: [PATCH 1/2] feat(PLU-293): disable private analyses for ENTHUSIAST tier users --- .revengai/features.json | 4 ++ reai_toolkit/features/upload/upload.py | 21 ++++++- reai_toolkit/features/upload/upload_dialog.py | 27 ++++++++- scripts/emit_features.py | 4 ++ tests/unit/upload/test_dialog.py | 55 +++++++++++++++++++ tests/unit/upload/test_service.py | 21 +++++++ 6 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/unit/upload/test_dialog.py diff --git a/.revengai/features.json b/.revengai/features.json index 1f5f22e..ee62d78 100644 --- a/.revengai/features.json +++ b/.revengai/features.json @@ -25,6 +25,10 @@ "data_types_sync": { "status": "yes" }, + "disable_private_analyses": { + "note": "Disable Private Analyses - Enthusiast Tier Only. Disables private analyses creations for the Enthusiast Tier.", + "status": "yes" + }, "fs_binary_filter": { "status": "yes" }, diff --git a/reai_toolkit/features/upload/upload.py b/reai_toolkit/features/upload/upload.py index e52d4ea..06b10e5 100755 --- a/reai_toolkit/features/upload/upload.py +++ b/reai_toolkit/features/upload/upload.py @@ -5,10 +5,29 @@ from reai_toolkit.utils import PeriodicChecker from reai_toolkit.utils.core.sync import AnalysisSyncService +ENTHUSIAST_TIER = "ENTHUSIAST" + class BinaryUploader: def __init__(self, config): self.config = config - + + def get_user_tier(self): + """Fetch the authenticated user's subscription tier via GET /v2/iam/me. + + Returns (success, tier) where ``tier`` is the raw tier string + (e.g. "ENTHUSIAST") or None when the lookup fails. + """ + try: + with self.config.create_api_client() as api_client: + api_instance = revengai.IAMUsersApi(api_client) + user = api_instance.get_me() + tier = user.tier + log_info(f"RevEng.AI | User tier: {tier}") + return True, tier + except Exception as e: + log_error(f"RevEng.AI | Failed to get user tier: {str(e)}") + return False, None + def get_models(self, bv: BinaryView): try: with self.config.create_api_client() as api_client: diff --git a/reai_toolkit/features/upload/upload_dialog.py b/reai_toolkit/features/upload/upload_dialog.py index fd1dff9..95c38ae 100755 --- a/reai_toolkit/features/upload/upload_dialog.py +++ b/reai_toolkit/features/upload/upload_dialog.py @@ -1,8 +1,14 @@ from binaryninja import log_error from PySide6.QtCore import QCoreApplication from reai_toolkit.utils import create_progress_dialog, DataThread +from reai_toolkit.features.upload.upload import ENTHUSIAST_TIER from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QRadioButton, QButtonGroup, QLineEdit, QGroupBox, QFileDialog, QMessageBox +PRIVATE_DISABLED_TOOLTIP = ( + "Private analyses aren't available on the Enthusiast tier.\n" + "Upgrade your plan to create private analyses." +) + class UploadDialog(QDialog): def __init__(self, config, uploader, bv): super().__init__() @@ -55,7 +61,9 @@ def init_ui(self): privacy_layout.addWidget(self.public_radio) privacy_group.setLayout(privacy_layout) layout.addWidget(privacy_group) - + + self._apply_tier_restrictions() + button_layout = QHBoxLayout() self.process_button = QPushButton("Process") self.process_button.setStyleSheet(""" @@ -89,7 +97,22 @@ def init_ui(self): self.show() QCoreApplication.processEvents() - + + def _apply_tier_restrictions(self): + """Disable private-analysis creation for tiers that aren't allowed it. + + Enthusiast-tier accounts can't create private analyses, so the + "Private to you" option is disabled and the upload is forced to + public. A tooltip explains why on hover. If the tier lookup fails the + option is left enabled (the backend still enforces the restriction). + """ + ok, tier = self.uploader.get_user_tier() + if ok and tier == ENTHUSIAST_TIER: + self.private_radio.setChecked(False) + self.private_radio.setEnabled(False) + self.private_radio.setToolTip(PRIVATE_DISABLED_TOOLTIP) + self.public_radio.setChecked(True) + def upload_binary(self): self.progress = create_progress_dialog(self, "RevEng.AI Upload", "Uploading binary to RevEng.AI...") self.progress.show() diff --git a/scripts/emit_features.py b/scripts/emit_features.py index 9367b9a..3624add 100644 --- a/scripts/emit_features.py +++ b/scripts/emit_features.py @@ -29,6 +29,10 @@ "comment_sync": {"status": "partial"}, "search": {"status": "yes"}, "ai_decompilation_summary": {"status": "yes"}, + "disable_private_analyses": { + "status": "yes", + "note": "Disable Private Analyses - Enthusiast Tier Only. Disables private analyses creations for the Enthusiast Tier.", + }, } MANIFEST_PATH = Path(__file__).resolve().parent.parent / ".revengai" / "features.json" diff --git a/tests/unit/upload/test_dialog.py b/tests/unit/upload/test_dialog.py new file mode 100644 index 0000000..4779f57 --- /dev/null +++ b/tests/unit/upload/test_dialog.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("binaryninja") + +from reai_toolkit.features.upload import upload_dialog as dialog_mod + + +@pytest.fixture(scope="module", autouse=True) +def _qapp(): + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + yield app + + +def _make_dialog(tier_result): + uploader = MagicMock() + uploader.get_user_tier.return_value = tier_result + dialog = dialog_mod.UploadDialog(MagicMock(), uploader, MagicMock()) + return dialog + + +def test_enthusiast_disables_private(): + dialog = _make_dialog((True, dialog_mod.ENTHUSIAST_TIER)) + try: + assert dialog.private_radio.isEnabled() is False + assert dialog.private_radio.isChecked() is False + assert dialog.public_radio.isChecked() is True + assert dialog.private_radio.toolTip() == dialog_mod.PRIVATE_DISABLED_TOOLTIP + assert dialog.get_upload_options()["is_private"] is False + finally: + dialog.close() + + +def test_non_enthusiast_keeps_private_enabled(): + dialog = _make_dialog((True, "REVERSER")) + try: + assert dialog.private_radio.isEnabled() is True + assert dialog.private_radio.isChecked() is True + assert dialog.private_radio.toolTip() == "" + assert dialog.get_upload_options()["is_private"] is True + finally: + dialog.close() + + +def test_tier_lookup_failure_leaves_private_enabled(): + dialog = _make_dialog((False, None)) + try: + assert dialog.private_radio.isEnabled() is True + assert dialog.private_radio.isChecked() is True + assert dialog.get_upload_options()["is_private"] is True + finally: + dialog.close() diff --git a/tests/unit/upload/test_service.py b/tests/unit/upload/test_service.py index 66b58cf..ee28059 100644 --- a/tests/unit/upload/test_service.py +++ b/tests/unit/upload/test_service.py @@ -48,3 +48,24 @@ def test_upload_file_surfaces_error(uploader, mocker, tmp_path): assert ok is False assert "boom" in message + + +def test_get_user_tier_returns_tier(uploader, mocker): + api = mocker.patch.object(upload_mod.revengai, "IAMUsersApi").return_value + api.get_me.return_value.tier = upload_mod.ENTHUSIAST_TIER + + ok, tier = uploader.get_user_tier() + + assert ok is True + assert tier == upload_mod.ENTHUSIAST_TIER + api.get_me.assert_called_once_with() + + +def test_get_user_tier_surfaces_failure(uploader, mocker): + api = mocker.patch.object(upload_mod.revengai, "IAMUsersApi").return_value + api.get_me.side_effect = Exception("boom") + + ok, tier = uploader.get_user_tier() + + assert ok is False + assert tier is None From e7801cdf603866a2b4791305d5b164524c435911 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 26 Jun 2026 17:46:19 +0100 Subject: [PATCH 2/2] fix: tests --- tests/unit/upload/test_dialog.py | 64 +++++++++++++++----------------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/tests/unit/upload/test_dialog.py b/tests/unit/upload/test_dialog.py index 4779f57..b9ee4b8 100644 --- a/tests/unit/upload/test_dialog.py +++ b/tests/unit/upload/test_dialog.py @@ -7,49 +7,43 @@ from reai_toolkit.features.upload import upload_dialog as dialog_mod -@pytest.fixture(scope="module", autouse=True) -def _qapp(): - from PySide6.QtWidgets import QApplication +def _fake_dialog(tier_result): + """A stand-in for UploadDialog exposing only what _apply_tier_restrictions touches. - app = QApplication.instance() or QApplication([]) - yield app + Real QWidget construction aborts under Binary Ninja headless CI, so we drive + the method with a mock self instead of instantiating the dialog. + """ + dlg = MagicMock() + dlg.uploader.get_user_tier.return_value = tier_result + return dlg -def _make_dialog(tier_result): - uploader = MagicMock() - uploader.get_user_tier.return_value = tier_result - dialog = dialog_mod.UploadDialog(MagicMock(), uploader, MagicMock()) - return dialog +def test_enthusiast_disables_private(): + dlg = _fake_dialog((True, dialog_mod.ENTHUSIAST_TIER)) + dialog_mod.UploadDialog._apply_tier_restrictions(dlg) -def test_enthusiast_disables_private(): - dialog = _make_dialog((True, dialog_mod.ENTHUSIAST_TIER)) - try: - assert dialog.private_radio.isEnabled() is False - assert dialog.private_radio.isChecked() is False - assert dialog.public_radio.isChecked() is True - assert dialog.private_radio.toolTip() == dialog_mod.PRIVATE_DISABLED_TOOLTIP - assert dialog.get_upload_options()["is_private"] is False - finally: - dialog.close() + dlg.private_radio.setChecked.assert_called_once_with(False) + dlg.private_radio.setEnabled.assert_called_once_with(False) + dlg.private_radio.setToolTip.assert_called_once_with(dialog_mod.PRIVATE_DISABLED_TOOLTIP) + dlg.public_radio.setChecked.assert_called_once_with(True) def test_non_enthusiast_keeps_private_enabled(): - dialog = _make_dialog((True, "REVERSER")) - try: - assert dialog.private_radio.isEnabled() is True - assert dialog.private_radio.isChecked() is True - assert dialog.private_radio.toolTip() == "" - assert dialog.get_upload_options()["is_private"] is True - finally: - dialog.close() + dlg = _fake_dialog((True, "REVERSER")) + + dialog_mod.UploadDialog._apply_tier_restrictions(dlg) + + dlg.private_radio.setEnabled.assert_not_called() + dlg.private_radio.setToolTip.assert_not_called() + dlg.public_radio.setChecked.assert_not_called() def test_tier_lookup_failure_leaves_private_enabled(): - dialog = _make_dialog((False, None)) - try: - assert dialog.private_radio.isEnabled() is True - assert dialog.private_radio.isChecked() is True - assert dialog.get_upload_options()["is_private"] is True - finally: - dialog.close() + dlg = _fake_dialog((False, None)) + + dialog_mod.UploadDialog._apply_tier_restrictions(dlg) + + dlg.private_radio.setEnabled.assert_not_called() + dlg.private_radio.setToolTip.assert_not_called() + dlg.public_radio.setChecked.assert_not_called()