77from fastapi .testclient import TestClient
88
99from module .api import v1
10- from module .api .config import _sanitize_dict
10+ from module .api .config import _sanitize_dict , _restore_masked
1111from module .models .config import Config
1212from module .security .api import get_current_user
1313
@@ -306,24 +306,20 @@ def test_non_sensitive_keys_pass_through(self):
306306
307307 def test_nested_dict_recursed (self ):
308308 """Nested dicts are processed recursively."""
309- result = _sanitize_dict ({
310- "downloader" : {
311- "host" : "localhost" ,
312- "password" : "secret" ,
309+ result = _sanitize_dict (
310+ {
311+ "downloader" : {
312+ "host" : "localhost" ,
313+ "password" : "secret" ,
314+ }
313315 }
314- } )
316+ )
315317 assert result ["downloader" ]["host" ] == "localhost"
316318 assert result ["downloader" ]["password" ] == "********"
317319
318320 def test_deeply_nested_dict (self ):
319321 """Deeply nested sensitive keys are masked."""
320- result = _sanitize_dict ({
321- "level1" : {
322- "level2" : {
323- "api_key" : "deep-secret"
324- }
325- }
326- })
322+ result = _sanitize_dict ({"level1" : {"level2" : {"api_key" : "deep-secret" }}})
327323 assert result ["level1" ]["level2" ]["api_key" ] == "********"
328324
329325 def test_non_string_value_not_masked (self ):
@@ -338,17 +334,33 @@ def test_empty_dict(self):
338334
339335 def test_mixed_sensitive_and_plain (self ):
340336 """Mix of sensitive and plain keys handled correctly."""
341- result = _sanitize_dict ({
342- "username" : "admin" ,
343- "password" : "secret" ,
344- "host" : "10.0.0.1" ,
345- "token" : "jwt-abc" ,
346- })
337+ result = _sanitize_dict (
338+ {
339+ "username" : "admin" ,
340+ "password" : "secret" ,
341+ "host" : "10.0.0.1" ,
342+ "token" : "jwt-abc" ,
343+ }
344+ )
347345 assert result ["username" ] == "admin"
348346 assert result ["host" ] == "10.0.0.1"
349347 assert result ["password" ] == "********"
350348 assert result ["token" ] == "********"
351349
350+ def test_sanitize_list_of_dicts (self ):
351+ """Lists containing dicts are recursed into."""
352+ result = _sanitize_dict (
353+ {
354+ "providers" : [
355+ {"type" : "telegram" , "token" : "secret-token" },
356+ {"type" : "bark" , "token" : "another-secret" },
357+ ]
358+ }
359+ )
360+ assert result ["providers" ][0 ]["token" ] == "********"
361+ assert result ["providers" ][1 ]["token" ] == "********"
362+ assert result ["providers" ][0 ]["type" ] == "telegram"
363+
352364 def test_get_config_masks_sensitive_fields (self , authed_client ):
353365 """GET /config/get response masks password and api_key fields."""
354366 test_config = Config ()
@@ -360,3 +372,200 @@ def test_get_config_masks_sensitive_fields(self, authed_client):
360372 assert data ["downloader" ]["password" ] == "********"
361373 # OpenAI api_key should be masked (it's an empty string but still masked)
362374 assert data ["experimental_openai" ]["api_key" ] == "********"
375+
376+
377+ # ---------------------------------------------------------------------------
378+ # _restore_masked unit tests (#995)
379+ # ---------------------------------------------------------------------------
380+
381+
382+ class TestRestoreMasked :
383+ """Issue #995: Masked passwords must not overwrite real credentials."""
384+
385+ def test_masked_password_restored (self ):
386+ """Masked password is replaced with the real stored value."""
387+ incoming = {"password" : "********" }
388+ current = {"password" : "real_secret" }
389+ _restore_masked (incoming , current )
390+ assert incoming ["password" ] == "real_secret"
391+
392+ def test_new_password_preserved (self ):
393+ """Non-masked password value is kept as-is."""
394+ incoming = {"password" : "new_password" }
395+ current = {"password" : "old_password" }
396+ _restore_masked (incoming , current )
397+ assert incoming ["password" ] == "new_password"
398+
399+ def test_nested_masked_password_restored (self ):
400+ """Masked password inside nested dict is restored."""
401+ incoming = {"downloader" : {"host" : "10.0.0.1" , "password" : "********" }}
402+ current = {"downloader" : {"host" : "10.0.0.1" , "password" : "adminadmin" }}
403+ _restore_masked (incoming , current )
404+ assert incoming ["downloader" ]["password" ] == "adminadmin"
405+
406+ def test_nested_new_password_preserved (self ):
407+ """Non-masked password inside nested dict is kept."""
408+ incoming = {"downloader" : {"password" : "changed" }}
409+ current = {"downloader" : {"password" : "old" }}
410+ _restore_masked (incoming , current )
411+ assert incoming ["downloader" ]["password" ] == "changed"
412+
413+ def test_multiple_sensitive_fields (self ):
414+ """All sensitive fields are handled independently."""
415+ incoming = {
416+ "downloader" : {"password" : "********" },
417+ "proxy" : {"password" : "new_proxy_pass" },
418+ "experimental_openai" : {"api_key" : "********" },
419+ }
420+ current = {
421+ "downloader" : {"password" : "qb_pass" },
422+ "proxy" : {"password" : "old_proxy_pass" },
423+ "experimental_openai" : {"api_key" : "sk-real-key" },
424+ }
425+ _restore_masked (incoming , current )
426+ assert incoming ["downloader" ]["password" ] == "qb_pass"
427+ assert incoming ["proxy" ]["password" ] == "new_proxy_pass"
428+ assert incoming ["experimental_openai" ]["api_key" ] == "sk-real-key"
429+
430+ def test_non_sensitive_mask_value_untouched (self ):
431+ """A non-sensitive key with '********' value is not modified."""
432+ incoming = {"host" : "********" }
433+ current = {"host" : "10.0.0.1" }
434+ _restore_masked (incoming , current )
435+ assert incoming ["host" ] == "********"
436+
437+ def test_list_of_dicts_restored (self ):
438+ """Masked tokens inside list items are restored."""
439+ incoming = {
440+ "providers" : [
441+ {"type" : "telegram" , "token" : "********" },
442+ {"type" : "bark" , "token" : "new-bark-token" },
443+ ]
444+ }
445+ current = {
446+ "providers" : [
447+ {"type" : "telegram" , "token" : "real-tg-token" },
448+ {"type" : "bark" , "token" : "old-bark-token" },
449+ ]
450+ }
451+ _restore_masked (incoming , current )
452+ assert incoming ["providers" ][0 ]["token" ] == "real-tg-token"
453+ assert incoming ["providers" ][1 ]["token" ] == "new-bark-token"
454+
455+ def test_empty_dicts (self ):
456+ """Empty dicts don't cause errors."""
457+ _restore_masked ({}, {})
458+
459+ def test_round_trip_preserves_credentials (self ):
460+ """Full round-trip: sanitize then restore recovers original values."""
461+ original = {
462+ "downloader" : {"host" : "10.0.0.1" , "password" : "secret123" },
463+ "experimental_openai" : {"api_key" : "sk-abc" , "model" : "gpt-4" },
464+ }
465+ sanitized = _sanitize_dict (original )
466+ assert sanitized ["downloader" ]["password" ] == "********"
467+ assert sanitized ["experimental_openai" ]["api_key" ] == "********"
468+
469+ _restore_masked (sanitized , original )
470+ assert sanitized ["downloader" ]["password" ] == "secret123"
471+ assert sanitized ["experimental_openai" ]["api_key" ] == "sk-abc"
472+ assert sanitized ["downloader" ]["host" ] == "10.0.0.1"
473+ assert sanitized ["experimental_openai" ]["model" ] == "gpt-4"
474+
475+ def test_update_config_preserves_password_when_masked (
476+ self , authed_client , mock_settings
477+ ):
478+ """PATCH /config/update must not overwrite a real password with '********'."""
479+ mock_settings .dict .return_value = {
480+ "program" : {"rss_time" : 900 , "rename_time" : 60 , "webui_port" : 7892 },
481+ "downloader" : {
482+ "type" : "qbittorrent" ,
483+ "host" : "192.168.1.1:8080" ,
484+ "username" : "admin" ,
485+ "password" : "realpassword" ,
486+ "path" : "/downloads" ,
487+ "ssl" : True ,
488+ },
489+ "rss_parser" : {"enable" : True , "filter" : [], "language" : "zh" },
490+ "bangumi_manage" : {
491+ "enable" : True ,
492+ "eps_complete" : False ,
493+ "rename_method" : "pn" ,
494+ "group_tag" : False ,
495+ "remove_bad_torrent" : False ,
496+ },
497+ "log" : {"debug_enable" : False },
498+ "proxy" : {
499+ "enable" : False ,
500+ "type" : "http" ,
501+ "host" : "" ,
502+ "port" : 0 ,
503+ "username" : "" ,
504+ "password" : "" ,
505+ },
506+ "notification" : {
507+ "enable" : False ,
508+ "type" : "telegram" ,
509+ "token" : "" ,
510+ "chat_id" : "" ,
511+ },
512+ "experimental_openai" : {
513+ "enable" : False ,
514+ "api_key" : "" ,
515+ "api_base" : "https://api.openai.com/v1" ,
516+ "api_type" : "openai" ,
517+ "api_version" : "2023-05-15" ,
518+ "model" : "gpt-3.5-turbo" ,
519+ "deployment_id" : "" ,
520+ },
521+ }
522+ payload = {
523+ "program" : {"rss_time" : 900 , "rename_time" : 60 , "webui_port" : 7892 },
524+ "downloader" : {
525+ "type" : "qbittorrent" ,
526+ "host" : "192.168.1.1:8080" ,
527+ "username" : "admin" ,
528+ "password" : "********" ,
529+ "path" : "/downloads" ,
530+ "ssl" : False ,
531+ },
532+ "rss_parser" : {"enable" : True , "filter" : [], "language" : "zh" },
533+ "bangumi_manage" : {
534+ "enable" : True ,
535+ "eps_complete" : False ,
536+ "rename_method" : "pn" ,
537+ "group_tag" : False ,
538+ "remove_bad_torrent" : False ,
539+ },
540+ "log" : {"debug_enable" : False },
541+ "proxy" : {
542+ "enable" : False ,
543+ "type" : "http" ,
544+ "host" : "" ,
545+ "port" : 0 ,
546+ "username" : "" ,
547+ "password" : "" ,
548+ },
549+ "notification" : {
550+ "enable" : False ,
551+ "type" : "telegram" ,
552+ "token" : "" ,
553+ "chat_id" : "" ,
554+ },
555+ "experimental_openai" : {
556+ "enable" : False ,
557+ "api_key" : "" ,
558+ "api_base" : "https://api.openai.com/v1" ,
559+ "api_type" : "openai" ,
560+ "api_version" : "2023-05-15" ,
561+ "model" : "gpt-3.5-turbo" ,
562+ "deployment_id" : "" ,
563+ },
564+ }
565+ with patch ("module.api.config.settings" , mock_settings ):
566+ response = authed_client .patch ("/api/v1/config/update" , json = payload )
567+
568+ assert response .status_code == 200
569+ saved = mock_settings .save .call_args [1 ]["config_dict" ]
570+ assert saved ["downloader" ]["password" ] == "realpassword"
571+ assert saved ["downloader" ]["ssl" ] is False
0 commit comments