diff --git a/keepercommander/commands/nested_share_folder/record_commands.py b/keepercommander/commands/nested_share_folder/record_commands.py index 6a0480646..e5ac2f4fe 100644 --- a/keepercommander/commands/nested_share_folder/record_commands.py +++ b/keepercommander/commands/nested_share_folder/record_commands.py @@ -301,9 +301,7 @@ def execute(self, params, **kwargs): return self.warnings.clear() result = _nsf.update_record_v3( - params=params, record_uid=record_uid, - title=kwargs.get('title'), record_type=record_type, - fields=fields or None, notes=kwargs.get('notes'), + params=params, record_uid=record_uid, data=merged, ) check_result(result, 'nsf-record-update') params.sync_data = True diff --git a/keepercommander/nested_share_folder/record_api.py b/keepercommander/nested_share_folder/record_api.py index 9fc189bc0..1ac17c2fc 100644 --- a/keepercommander/nested_share_folder/record_api.py +++ b/keepercommander/nested_share_folder/record_api.py @@ -151,6 +151,28 @@ def create_record_v3(params, record_type='', title='', fields=None, raise KeeperApiError('no_results', 'No results from record creation') +def _load_existing_record_data(params, record_uid, rec=None): + """Load decrypted record JSON for update""" + candidates = [] + if rec is not None: + candidates.append(rec.get('data_unencrypted')) + cache_rec = getattr(params, 'record_cache', {}).get(record_uid) or {} + candidates.append(cache_rec.get('data_unencrypted')) + nsf_data = getattr(params, 'nested_share_record_data', {}).get(record_uid) or {} + candidates.append(nsf_data.get('data_json')) + + for raw in candidates: + if raw is None: + continue + if isinstance(raw, bytes): + return json.loads(raw.decode('utf-8')) + if isinstance(raw, str): + return json.loads(raw) + if isinstance(raw, dict): + return raw.copy() + return None + + def update_record_v3(params, record_uid, data=None, title=None, record_type=None, fields=None, notes=None, non_shared_data=None, revision=None): @@ -165,13 +187,7 @@ def update_record_v3(params, record_uid, data=None, title=None, rk = rec.get('record_key_unencrypted') or get_record_key(params, record_uid) if data is None: - existing = None - if 'data_unencrypted' in rec: - raw = rec['data_unencrypted'] - if isinstance(raw, bytes): - existing = json.loads(raw.decode('utf-8')) - elif isinstance(raw, str): - existing = json.loads(raw) + existing = _load_existing_record_data(params, record_uid, rec) data = existing.copy() if existing else {'fields': []} if title is not None: data['title'] = title diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index aa72a4438..d48edcbf0 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -1387,6 +1387,91 @@ def setUp(self): def tearDown(self): mock.patch.stopall() + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + def test_update_record_v3_preserves_login_fields_from_record_cache(self, mock_update): + """nested_share_records has no data_unencrypted; login fields must come from record_cache.""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + existing = { + 'type': 'login', + 'title': 'Prod Login', + 'fields': [ + {'type': 'login', 'value': ['alice']}, + {'type': 'password', 'value': ['OldPass123']}, + {'type': 'url', 'value': ['https://example.com']}, + ], + } + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: { + 'revision': 1, + 'record_key_unencrypted': robj['record_key_unencrypted'], + 'data_unencrypted': json.dumps(existing).encode('utf-8'), + }}, + ) + mock_rs = Mock() + mock_rec = Mock() + mock_rec.status = record_pb2.RS_SUCCESS + mock_rec.message = '' + mock_rs.records = [mock_rec] + mock_rs.revision = 2 + mock_update.return_value = mock_rs + + result = update_record_v3(params, ruid, fields={'password': 'NewPass456'}) + self.assertTrue(result['success']) + + ru = mock_update.call_args[0][1][0] + decrypted = json.loads( + crypto.decrypt_aes_v2(ru.data, robj['record_key_unencrypted']).decode('utf-8').rstrip('\x00') + ) + by_type = {f['type']: f['value'] for f in decrypted['fields']} + self.assertEqual(decrypted['type'], 'login') + self.assertEqual(decrypted['title'], 'Prod Login') + self.assertEqual(by_type['login'], ['alice']) + self.assertEqual(by_type['password'], ['NewPass456']) + self.assertEqual(by_type['url'], ['https://example.com']) + + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + def test_update_record_v3_preserves_login_fields_from_nsf_record_data(self, mock_update): + """Fallback to nested_share_record_data when record_cache has no decrypted JSON.""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + existing = { + 'type': 'login', + 'title': 'NSF Login', + 'fields': [ + {'type': 'login', 'value': ['bob']}, + {'type': 'password', 'value': ['KeepMe']}, + ], + } + params = _make_params( + nested_share_records={ruid: robj}, + nested_share_record_data={ruid: {'data_json': existing}}, + ) + mock_rs = Mock() + mock_rec = Mock() + mock_rec.status = record_pb2.RS_SUCCESS + mock_rec.message = '' + mock_rs.records = [mock_rec] + mock_rs.revision = 2 + mock_update.return_value = mock_rs + + result = update_record_v3(params, ruid, fields={'login': 'bob2'}) + self.assertTrue(result['success']) + + ru = mock_update.call_args[0][1][0] + decrypted = json.loads( + crypto.decrypt_aes_v2(ru.data, robj['record_key_unencrypted']).decode('utf-8').rstrip('\x00') + ) + by_type = {f['type']: f['value'] for f in decrypted['fields']} + self.assertEqual(decrypted['title'], 'NSF Login') + self.assertEqual(by_type['login'], ['bob2']) + self.assertEqual(by_type['password'], ['KeepMe']) + @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') @patch('keepercommander.nested_share_folder.record_api.encrypt_for_recipient') @patch('keepercommander.nested_share_folder.record_api.get_user_public_key')