Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install -e ".[dev]"

- name: Run tests
env:
Expand Down
5 changes: 3 additions & 2 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2079,9 +2079,10 @@ async def backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
query = update.callback_query
user_id = update.effective_user.id

parse_mode = _get_user_parse_mode(user_id)

await query.answer()

# Show progress message
progress_msg = await _send_message_safely(query.message.chat, "🔄 **Creating backup...**", user_id, preformatted=True)

Expand Down
26 changes: 18 additions & 8 deletions src/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,10 @@ def log_message(message: str) -> None:
# Create backup directory with timestamp
timestamp = time.strftime("%Y%m%d_%H%M%S")
backup_dir = REPO_ROOT / f"backup_{timestamp}"
# Optional files are skipped (with a warning) when absent; critical files must exist.
data_files = ["config.json", "database.db", "var/encryption.key", "var/pq_encryption.key"]
optional_data_files = {"var/encryption.key", "var/pq_encryption.key"}
critical_data_files = {"config.json", "database.db"}

log_message("[2/8] Creating comprehensive backup...")
try:
Expand All @@ -194,11 +197,18 @@ def log_message(message: str) -> None:
# Backup critical files
for file in data_files:
src_path = REPO_ROOT / file
if src_path.exists():
dst_path = backup_dir / file
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dst_path)
log_message(f" ✓ Backed up {file}")
if not src_path.exists():
if file in optional_data_files:
logger.warning(f"Optional file {file} not found - skipping backup")
continue
raise FileNotFoundError(f"Required data file missing: {file}")
if src_path.stat().st_size == 0:
logger.warning(f"File {file} is empty - skipping backup")
continue
dst_path = backup_dir / file
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dst_path)
log_message(f" ✓ Backed up {file}")

# Backup source code (excluding large dirs)
ignore_patterns = shutil.ignore_patterns(
Expand All @@ -219,12 +229,12 @@ def log_message(message: str) -> None:
time.sleep(3) # Additional wait time

log_message("[4/8] Validating backup integrity...")
# Verify critical files exist in backup
# Verify critical files exist in backup (optional files may have been skipped)
for file in ["config.json", "database.db"]:
backup_file = backup_dir / file
if not backup_file.exists():
if file in critical_data_files and not backup_file.exists():
raise ValueError(f"Critical file missing in backup: {file}")
if backup_file.stat().st_size == 0:
if backup_file.exists() and backup_file.stat().st_size == 0:
raise ValueError(f"Backup file is empty: {file}")

log_message("[5/8] Downloading new version...")
Expand Down
9 changes: 7 additions & 2 deletions tests/test_bot_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ async def test_handle_server_connection_e2e(mocker, conn_type):
# Patch the config to authorize the user
mocker.patch.object(main.config, 'whitelisted_users', [user_id])

# handle_server_connection rate-limits per user_id (3s). The parametrized cases
# share a user id, so clear the cooldown to avoid an early return.
main._user_cooldowns.clear()


# 2. Execute the handler
try:
Expand All @@ -99,7 +103,8 @@ async def test_handle_server_connection_e2e(mocker, conn_type):
assert mock_conn.waited

# Verify bot interactions
update.callback_query.edit_message_text.assert_called()
# handle_server_connection edits query.message via _edit_message_safely()
update.callback_query.message.edit_message_text.assert_called()
# The text is in the second call to edit_message_text
final_call_args = update.callback_query.edit_message_text.call_args_list[1]
final_call_args = update.callback_query.message.edit_message_text.call_args_list[1]
assert "Connected to" in final_call_args.args[0]
49 changes: 27 additions & 22 deletions tests/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import os

import pytest
Expand All @@ -10,36 +11,40 @@


@pytest.fixture(autouse=True)
def clear_key_cache(monkeypatch):
"""Reset memoized cipher between tests to avoid cross-test bleed."""
security._get_cipher.cache_clear()
monkeypatch.delenv("TLA_ENCRYPTION_KEY", raising=False)
def clear_key_cache():
"""Reset memoized cipher dictionary between tests to avoid cross-test bleed."""
security._get_ciphers.cache_clear()
yield
security._get_cipher.cache_clear()


def test_env_key_takes_priority(monkeypatch, tmp_path):
"""Explicit environment variable should be used over files."""
keyfile = tmp_path / "encryption.key"
keyfile.write_text("should-not-be-used")
monkeypatch.setenv("TLA_ENCRYPTION_KEY", "K1pGzzKquqCPIAHJ_Tz6lhZKBiJMOrlrNUmt1Bd6Rwk=")
monkeypatch.setenv("TLA_ENCRYPTION_KEY_FILE", str(keyfile))

cipher = security._get_cipher()
decrypted = cipher.decrypt(cipher.encrypt(b"secret"))

assert decrypted == b"secret"
security._get_ciphers.cache_clear()


def test_keyfile_is_created_when_missing(monkeypatch, tmp_path):
"""A new Fernet key should be generated and persisted when absent."""
"""A new Fernet key file (JSON format) should be generated and persisted when absent."""
keyfile = tmp_path / "nested" / "encryption.key"
monkeypatch.setenv("TLA_ENCRYPTION_KEY_FILE", str(keyfile))

cipher = security._get_cipher()
decrypted = cipher.decrypt(cipher.encrypt(b"data"))
encrypted = security.encrypt_secret("data")
assert encrypted is not None
decrypted = security.decrypt_secret(encrypted)
assert decrypted == "data"

assert decrypted == b"data"
assert keyfile.exists()
assert os.stat(keyfile).st_mode & 0o777 == 0o600

data = json.loads(keyfile.read_text(encoding="utf-8"))
assert data.get("primary_key") == "v1"
assert "v1" in data.get("keys", {})


def test_encrypt_decrypt_roundtrip(monkeypatch, tmp_path):
"""Encrypt and decrypt roundtrip matches original text."""
keyfile = tmp_path / "encryption.key"
monkeypatch.setenv("TLA_ENCRYPTION_KEY_FILE", str(keyfile))

secret = "my-super-secret-ssh-password-12345"
encrypted = security.encrypt_secret(secret)
assert encrypted is not None
assert encrypted != secret.encode("utf-8")

decrypted = security.decrypt_secret(encrypted)
assert decrypted == secret
15 changes: 9 additions & 6 deletions tests/test_service_management.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from src.main import service_action_start, execute_service_action, AWAIT_SERVICE_NAME

@pytest.mark.asyncio
Expand All @@ -9,15 +9,16 @@ async def test_service_action_start(mock_config):
mock_config.whitelisted_users = [12345]
update = AsyncMock()
update.effective_user.id = 12345
update.effective_chat = MagicMock(id=12345)
update.callback_query.data = "check_service_server1"
context = AsyncMock()

result = await service_action_start(update, context)

assert result == AWAIT_SERVICE_NAME
update.callback_query.edit_message_text.assert_called_once_with(
"Please enter the name of the service to `check`.", parse_mode='Markdown'
)
update.callback_query.message.edit_message_text.assert_called_once()
args, kwargs = update.callback_query.message.edit_message_text.call_args
assert "Please enter the name of the service to `check`." in args[0]

@pytest.mark.asyncio
@patch('src.main.config')
Expand All @@ -27,6 +28,7 @@ async def test_execute_service_action(mock_ssh_manager, mock_config):
mock_config.whitelisted_users = [12345]
update = AsyncMock()
update.effective_user.id = 12345
update.effective_chat = MagicMock(id=12345)
update.message.text = "nginx"
context = AsyncMock()
context.user_data = {'service_action': 'status', 'alias': 'server1'}
Expand All @@ -43,5 +45,6 @@ async def mock_run_command(alias, command):
result = await execute_service_action(update, context)

assert result == ConversationHandler.END
# The assertions are now inside the mock_run_command function
update.message.reply_text.assert_called_once()
# The result message is sent via chat.send_message and then edited with the output
update.message.chat.send_message.assert_called_once()
# The assertions about the command/alias are inside mock_run_command above
42 changes: 28 additions & 14 deletions tests/test_system_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,23 @@ def authorized_update():
"""Creates a mock update object that will pass the @authorized decorator."""
update = AsyncMock(spec=Update)
update.effective_user = MagicMock(id=12345)
update.effective_chat = MagicMock(id=12345)
update.callback_query = AsyncMock()
update.callback_query.from_user = MagicMock(id=12345)
update.callback_query.message = AsyncMock()
return update

@pytest.fixture
def mock_translations():
"""Makes translate() pass keys through verbatim so assertions stay stable."""
with patch('src.main.translate', side_effect=lambda key, lang, **kw: key), \
patch('src.main._translate_for_user', side_effect=lambda user_id, key, **kw: key):
yield

def _edit_call(update):
"""Returns the first call args of the real edit target: query.message.edit_message_text."""
return update.callback_query.message.edit_message_text.call_args

@pytest.mark.asyncio
@patch('src.main.config')
async def test_system_commands_menu(mock_config, authorized_update):
Expand All @@ -22,9 +35,9 @@ async def test_system_commands_menu(mock_config, authorized_update):

await main.system_commands_menu(authorized_update, MagicMock(spec=ContextTypes.DEFAULT_TYPE))

authorized_update.callback_query.edit_message_text.assert_called_once()
call_args = authorized_update.callback_query.edit_message_text.call_args
assert "**⚙️ System Commands for test_alias**" in call_args[0][0]
authorized_update.callback_query.message.edit_message_text.assert_called_once()
call_args = _edit_call(authorized_update)
assert "System Commands for test_alias" in call_args[0][0]

reply_markup = call_args[1]['reply_markup']
assert isinstance(reply_markup, InlineKeyboardMarkup)
Expand All @@ -46,9 +59,9 @@ async def test_confirm_system_command(mock_config, authorized_update):

await main.confirm_system_command(authorized_update, MagicMock(spec=ContextTypes.DEFAULT_TYPE))

authorized_update.callback_query.edit_message_text.assert_called_once()
call_args = authorized_update.callback_query.edit_message_text.call_args
assert "**⚠️ Are you sure you want to reboot the server `test_alias`?**" in call_args[0][0]
authorized_update.callback_query.message.edit_message_text.assert_called_once()
call_args = _edit_call(authorized_update)
assert "Are you sure you want to reboot the server `test_alias`?" in call_args[0][0]

reply_markup = call_args[1]['reply_markup']
buttons = reply_markup.inline_keyboard
Expand All @@ -70,9 +83,10 @@ async def mock_run_command_gen(*args, **kwargs):

await main.execute_system_command(authorized_update, MagicMock(spec=ContextTypes.DEFAULT_TYPE))

authorized_update.callback_query.edit_message_text.assert_called_once_with(
authorized_update.callback_query.message.edit_message_text.assert_called_once_with(
"✅ **Command `reboot` sent to `test_alias` successfully.**",
parse_mode='Markdown'
parse_mode='MarkdownV2',
reply_markup=None
)

@pytest.mark.asyncio
Expand All @@ -92,9 +106,9 @@ async def mock_run_command_gen(user_id, alias, command):

await main.get_disk_usage(authorized_update, MagicMock(spec=ContextTypes.DEFAULT_TYPE))

authorized_update.callback_query.edit_message_text.assert_called_once()
call_args = authorized_update.callback_query.edit_message_text.call_args
assert "**💾 Disk Usage for `test_alias`**" in call_args[0][0]
authorized_update.callback_query.message.edit_message_text.assert_called_once()
call_args = _edit_call(authorized_update)
assert "Disk Usage for `test_alias`" in call_args[0][0]
assert "Filesystem" in call_args[0][0]

@pytest.mark.asyncio
Expand All @@ -114,7 +128,7 @@ async def mock_run_command_gen(user_id, alias, command):

await main.get_network_info(authorized_update, MagicMock(spec=ContextTypes.DEFAULT_TYPE))

authorized_update.callback_query.edit_message_text.assert_called_once()
call_args = authorized_update.callback_query.edit_message_text.call_args
assert "**🌐 Network Info for `test_alias`**" in call_args[0][0]
authorized_update.callback_query.message.edit_message_text.assert_called_once()
call_args = _edit_call(authorized_update)
assert "Network Info for `test_alias`" in call_args[0][0]
assert "LOOPBACK" in call_args[0][0]
Loading
Loading