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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 23 additions & 7 deletions keepercommander/nested_share_folder/record_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
85 changes: 85 additions & 0 deletions unit-tests/test_nested_share_folder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down