From 79a508bec5bc4611d81b92de2e5e6c2f019749bd Mon Sep 17 00:00:00 2001 From: Federico Stagni Date: Wed, 28 Oct 2026 17:46:56 +0100 Subject: [PATCH] fix: repair saved desktops not shown under My Desktops The JSON migration (v6.0.7) broke the state save/load round trip: the browser sends the state as an already serialized JSON text, so json.dumps() in web_saveAppState double-encoded it, and _safeDecode() returned the inner JSON string instead of the structure for both double-encoded and legacy DEncode data. As a result UP/listAppState returned strings, the frontend failed to build the 'My Desktops' tree (TypeError in oprVerifyDataStructure), and web_changeView rejected every desktop. Shared desktops kept working because their listing is metadata only. - store the state verbatim when it is already a JSON text - unwrap redundant JSON string layers in _safeDecode, restoring the historic json.loads(DEncode.decode(...)[0]) behavior; data saved with the broken versions decodes correctly and self-heals on next save - add regression tests covering all storage generations --- src/WebAppDIRAC/WebApp/handler/UPHandler.py | 23 +++++-- tests/Test_UPHandler.py | 73 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 tests/Test_UPHandler.py diff --git a/src/WebAppDIRAC/WebApp/handler/UPHandler.py b/src/WebAppDIRAC/WebApp/handler/UPHandler.py index 0afccdf15..341f1019b 100644 --- a/src/WebAppDIRAC/WebApp/handler/UPHandler.py +++ b/src/WebAppDIRAC/WebApp/handler/UPHandler.py @@ -33,14 +33,26 @@ def _safeDecode(data): raise ValueError(f"Decompressed data exceeds size limit ({_MAX_DECODED_SIZE} bytes)") try: - return json.loads(raw) + decoded = json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError): # Legacy data encoded with DEncode - validate depth before decoding _checkDEncodeDepth(raw) decoded = DEncode.decode(raw)[0] if not isinstance(decoded, (dict, list, str, int, float, bool, type(None))): raise ValueError("Stored data contains unsupported types after DEncode decode") - return decoded + + # Older encodings can wrap the actual JSON document in an extra string + # layer: legacy data is stored as a DEncode-encoded JSON text, and data + # saved between v6.0.7 and v6.0.8 was double-encoded (json.dumps() was + # applied to an already serialized JSON text). Unwrap these string + # layers, mirroring the historic json.loads(DEncode.decode(...)[0]) + # behavior, until a real structure or a plain non-JSON string is reached. + while isinstance(decoded, str): + try: + decoded = json.loads(decoded) + except (json.JSONDecodeError, UnicodeDecodeError): + break + return decoded def _checkDEncodeDepth(data): @@ -129,8 +141,11 @@ def web_saveAppState(self, obj, app, name, state): :return: dict """ up = UserProfileClient(f"Web/{obj}/{app}") - # Use JSON instead of DEncode for security and portability - data = base64.b64encode(zlib.compress(json.dumps(state).encode(), 9)) + # The client sends the state as an already serialized JSON text + # (Ext.JSON.encode on the browser side). Store it as is; calling + # json.dumps() on it would double-encode it. + raw = state if isinstance(state, str) else json.dumps(state) + data = base64.b64encode(zlib.compress(raw.encode(), 9)) # before we save the state (modify the state) we have to remember the actual access: ReadAccess and PublishAccess result = up.getVarPermissions(name) access = { diff --git a/tests/Test_UPHandler.py b/tests/Test_UPHandler.py new file mode 100644 index 000000000..010e1bda9 --- /dev/null +++ b/tests/Test_UPHandler.py @@ -0,0 +1,73 @@ +"""Regression tests for the state (de)serialization in UPHandler. + +Desktops and application states have been stored in several formats over +time: DEncode-encoded JSON text (up to v6.0.6), double-encoded JSON +(v6.0.7/v6.0.8, json.dumps applied to an already serialized JSON text) and +plain JSON text (current format). All of them must decode back to the +original structure, otherwise the frontend fails to build the "My Desktops" +tree (https://github.com/DIRACGrid/WebAppDIRAC issues from v6.0.7 release). +""" + +import base64 +import json +import sys +import zlib +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from WebAppDIRAC.WebApp.handler.UPHandler import _safeDecode # noqa: E402 + +STATE = { + "version": 2, + "data": [{"module": "DIRACSomeApp", "name": "a state"}], + "views": {"tabs": {"activeTab": 0}}, +} + + +def _compress(raw): + return base64.b64encode(zlib.compress(raw, 9)) + + +def test_decodeLegacyDEncodeEncodedJSON(): + # States saved before v6.0.7: DEncode.encode() of the JSON text + from DIRAC.Core.Utilities import DEncode + + blob = _compress(DEncode.encode(json.dumps(STATE))) + assert _safeDecode(blob) == STATE + + +def test_decodeDoubleEncodedJSON(): + # States saved with v6.0.7/v6.0.8: json.dumps() of the JSON text + blob = _compress(json.dumps(json.dumps(STATE)).encode()) + assert _safeDecode(blob) == STATE + + +def test_decodeCurrentFormat(): + # Current format: the JSON text sent by the browser, stored as is + blob = _compress(json.dumps(STATE).encode()) + assert _safeDecode(blob) == STATE + + +def test_decodePlainString(): + # Data that is neither JSON nor DEncode is rejected + blob = _compress(b"a plain string") + with pytest.raises(ValueError): + _safeDecode(blob) + + +def test_decodeJSONStringState(): + # A state that legitimately is a JSON string is unwrapped to that string + blob = _compress(json.dumps(json.dumps("just a string")).encode()) + assert _safeDecode(blob) == "just a string" + + +def test_decodeDeeplyNestedDENcodeRejected(): + # Defense-in-depth from the JSON migration must be kept: deeply nested + # legacy DEncode data is rejected instead of being decoded + nested = b"l" * 100 + b"e" * 100 # 100 nested empty lists + blob = _compress(nested) + with pytest.raises(ValueError): + _safeDecode(blob)