|
7 | 7 | from fastapi.testclient import TestClient |
8 | 8 |
|
9 | 9 | from module.api import v1 |
10 | | -from module.api.config import _sanitize_dict |
| 10 | +from module.api.config import _restore_sensitive, _sanitize_dict |
11 | 11 | from module.models.config import Config |
12 | 12 | from module.security.api import get_current_user |
13 | 13 |
|
@@ -360,3 +360,97 @@ def test_get_config_masks_sensitive_fields(self, authed_client): |
360 | 360 | assert data["downloader"]["password"] == "********" |
361 | 361 | # OpenAI api_key should be masked (it's an empty string but still masked) |
362 | 362 | assert data["experimental_openai"]["api_key"] == "********" |
| 363 | + |
| 364 | + |
| 365 | +# --------------------------------------------------------------------------- |
| 366 | +# _restore_sensitive unit tests |
| 367 | +# --------------------------------------------------------------------------- |
| 368 | + |
| 369 | + |
| 370 | +class TestRestoreSensitive: |
| 371 | + def test_restores_masked_password(self): |
| 372 | + """Masked password is replaced with the real value from current config.""" |
| 373 | + incoming = {"password": "********"} |
| 374 | + current = {"password": "realpassword"} |
| 375 | + result = _restore_sensitive(incoming, current) |
| 376 | + assert result["password"] == "realpassword" |
| 377 | + |
| 378 | + def test_non_masked_password_kept(self): |
| 379 | + """A newly supplied password (not the placeholder) is kept as-is.""" |
| 380 | + incoming = {"password": "newpassword"} |
| 381 | + current = {"password": "oldpassword"} |
| 382 | + result = _restore_sensitive(incoming, current) |
| 383 | + assert result["password"] == "newpassword" |
| 384 | + |
| 385 | + def test_non_sensitive_key_passed_through(self): |
| 386 | + """Non-sensitive keys are never touched.""" |
| 387 | + incoming = {"host": "192.168.1.1", "ssl": False} |
| 388 | + current = {"host": "10.0.0.1", "ssl": True} |
| 389 | + result = _restore_sensitive(incoming, current) |
| 390 | + assert result["host"] == "192.168.1.1" |
| 391 | + assert result["ssl"] is False |
| 392 | + |
| 393 | + def test_nested_dict_restored(self): |
| 394 | + """Masked values inside nested dicts are restored recursively.""" |
| 395 | + incoming = {"downloader": {"host": "192.168.1.1", "password": "********"}} |
| 396 | + current = {"downloader": {"host": "10.0.0.1", "password": "realpassword"}} |
| 397 | + result = _restore_sensitive(incoming, current) |
| 398 | + assert result["downloader"]["host"] == "192.168.1.1" |
| 399 | + assert result["downloader"]["password"] == "realpassword" |
| 400 | + |
| 401 | + def test_missing_key_in_current_keeps_placeholder(self): |
| 402 | + """If a sensitive key has no counterpart in current, keep the incoming value.""" |
| 403 | + incoming = {"password": "********"} |
| 404 | + current = {} |
| 405 | + result = _restore_sensitive(incoming, current) |
| 406 | + assert result["password"] == "********" |
| 407 | + |
| 408 | + def test_update_config_preserves_password_when_masked(self, authed_client, mock_settings): |
| 409 | + """PATCH /config/update must not overwrite a real password with '********'. |
| 410 | +
|
| 411 | + Reproduces the bug where the frontend sends back the masked placeholder |
| 412 | + and the backend used to save it verbatim, corrupting the stored credential. |
| 413 | + """ |
| 414 | + mock_settings.dict.return_value = { |
| 415 | + "program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892}, |
| 416 | + "downloader": { |
| 417 | + "type": "qbittorrent", |
| 418 | + "host": "192.168.1.1:8080", |
| 419 | + "username": "admin", |
| 420 | + "password": "realpassword", |
| 421 | + "path": "/downloads", |
| 422 | + "ssl": True, |
| 423 | + }, |
| 424 | + "rss_parser": {"enable": True, "filter": [], "language": "zh"}, |
| 425 | + "bangumi_manage": {"enable": True, "eps_complete": False, "rename_method": "pn", "group_tag": False, "remove_bad_torrent": False}, |
| 426 | + "log": {"debug_enable": False}, |
| 427 | + "proxy": {"enable": False, "type": "http", "host": "", "port": 0, "username": "", "password": ""}, |
| 428 | + "notification": {"enable": False, "type": "telegram", "token": "", "chat_id": ""}, |
| 429 | + "experimental_openai": {"enable": False, "api_key": "", "api_base": "https://api.openai.com/v1", "api_type": "openai", "api_version": "2023-05-15", "model": "gpt-3.5-turbo", "deployment_id": ""}, |
| 430 | + } |
| 431 | + # Frontend sends back masked password and ssl changed to False |
| 432 | + payload = { |
| 433 | + "program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892}, |
| 434 | + "downloader": { |
| 435 | + "type": "qbittorrent", |
| 436 | + "host": "192.168.1.1:8080", |
| 437 | + "username": "admin", |
| 438 | + "password": "********", # <-- masked placeholder from frontend |
| 439 | + "path": "/downloads", |
| 440 | + "ssl": False, # <-- the actual change the user made |
| 441 | + }, |
| 442 | + "rss_parser": {"enable": True, "filter": [], "language": "zh"}, |
| 443 | + "bangumi_manage": {"enable": True, "eps_complete": False, "rename_method": "pn", "group_tag": False, "remove_bad_torrent": False}, |
| 444 | + "log": {"debug_enable": False}, |
| 445 | + "proxy": {"enable": False, "type": "http", "host": "", "port": 0, "username": "", "password": ""}, |
| 446 | + "notification": {"enable": False, "type": "telegram", "token": "", "chat_id": ""}, |
| 447 | + "experimental_openai": {"enable": False, "api_key": "", "api_base": "https://api.openai.com/v1", "api_type": "openai", "api_version": "2023-05-15", "model": "gpt-3.5-turbo", "deployment_id": ""}, |
| 448 | + } |
| 449 | + with patch("module.api.config.settings", mock_settings): |
| 450 | + response = authed_client.patch("/api/v1/config/update", json=payload) |
| 451 | + |
| 452 | + assert response.status_code == 200 |
| 453 | + # The dict passed to save() must have the real password, not '********' |
| 454 | + saved = mock_settings.save.call_args[1]["config_dict"] |
| 455 | + assert saved["downloader"]["password"] == "realpassword" |
| 456 | + assert saved["downloader"]["ssl"] is False |
0 commit comments