From 032a0dc1b98d21776aa44b995b924bc80c77a36a Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Sat, 16 May 2026 17:13:48 -0500 Subject: [PATCH 1/3] fix: Adopt sessionToken from save/update response on ParseUser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse Server mints a fresh session row when password is set on an existing _User and revokeSessionOnPasswordReset is true (default since 9.x). The new sessionToken is embedded in the save response. Without adopting it, the global session in ParseCoreData keeps pointing at the prior session — which the server just destroyed — and every subsequent request fails with invalidSessionToken until the next login. Mirrors PFUser._mergeFromServerWithResult on iOS, which reads PFUserSessionTokenRESTKey out of the response and installs it. The helper is a no-op when the response carries no sessionToken (e.g. a plain field update with no password change), preserving the existing behavior for non-auth saves. --- packages/dart/lib/src/objects/parse_user.dart | 17 ++ .../parse_user_session_token_test.dart | 145 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart diff --git a/packages/dart/lib/src/objects/parse_user.dart b/packages/dart/lib/src/objects/parse_user.dart index c89a5e87d..bd5019e6e 100644 --- a/packages/dart/lib/src/objects/parse_user.dart +++ b/packages/dart/lib/src/objects/parse_user.dart @@ -493,8 +493,10 @@ class ParseUser extends ParseObject implements ParseCloneable { if (objectId == null) { return await signUp(); } else { + final String? tokenBefore = sessionToken; final ParseResponse response = await super.save(); if (response.success) { + _adoptResponseSessionTokenIfChanged(tokenBefore); await _onResponseSuccess(); } return response; @@ -506,14 +508,29 @@ class ParseUser extends ParseObject implements ParseCloneable { if (objectId == null) { return await signUp(); } else { + final String? tokenBefore = sessionToken; final ParseResponse response = await super.update(); if (response.success) { + _adoptResponseSessionTokenIfChanged(tokenBefore); await _onResponseSuccess(); } return response; } } + /// Adopt a new sessionToken from a save/update response. Parse Server + /// mints a fresh session when `password` is set on an existing _User + /// (revokeSessionOnPasswordReset, current default in 9.x); the prior + /// session is destroyed server-side, so the global session must be + /// updated or subsequent requests will fail with invalidSessionToken. + /// Mirrors iOS PFUser's _mergeFromServerWithResult. + void _adoptResponseSessionTokenIfChanged(String? tokenBefore) { + final String? tokenAfter = sessionToken; + if (tokenAfter == null || tokenAfter.isEmpty) return; + if (tokenAfter == tokenBefore) return; + ParseCoreData().setSessionId(tokenAfter); + } + Future _onResponseSuccess() async { await saveInStorage(keyParseStoreUser); } diff --git a/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart b/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart new file mode 100644 index 000000000..c221e1b6a --- /dev/null +++ b/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; + +import 'package:mockito/mockito.dart'; +import 'package:parse_server_sdk/parse_server_sdk.dart'; +import 'package:test/test.dart'; + +import '../../../parse_query_test.mocks.dart'; +import '../../../test_utils.dart'; + +void main() { + setUpAll(() async { + await initializeParse(); + }); + + group('ParseUser save()/update() — sessionToken adoption from response', () { + late MockParseClient client; + + const String userObjectId = 'sess123'; + final String putPath = Uri.parse( + '$serverUrl$keyEndPointClasses$keyClassUser/$userObjectId', + ).toString(); + + setUp(() { + client = MockParseClient(); + }); + + test( + 'when a save() response carries a sessionToken different from the ' + 'one sent, the SDK installs it as the global session token. Parse ' + 'Server mints a fresh session when password is set on an existing ' + '_User; the prior session is destroyed server-side, so the global ' + 'session must be updated or subsequent requests fail with ' + 'invalidSessionToken', + () async { + ParseCoreData().setSessionId('r:priorSession'); + + final ParseUser user = ParseUser(null, null, null, client: client); + user.fromJson({ + keyVarObjectId: userObjectId, + keyVarSessionToken: 'r:priorSession', + keyVarUsername: 'alice@example.com', + }); + + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + keyVarSessionToken: 'r:freshSession', + }), + ), + ); + + user.password = 'hunter2'; + + final ParseResponse response = await user.save(); + + expect(response.success, isTrue); + expect(ParseCoreData().sessionId, equals('r:freshSession')); + }, + ); + + test( + 'when a save() response does NOT carry a sessionToken, the global ' + 'session is left untouched. the previously-cached local sessionToken ' + 'on the user object must not be re-promoted to global state', + () async { + ParseCoreData().setSessionId('r:stableSession'); + + final ParseUser user = ParseUser(null, null, null, client: client); + user.fromJson({ + keyVarObjectId: userObjectId, + keyVarSessionToken: 'r:stableSession', + keyVarUsername: 'alice@example.com', + }); + + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + }), + ), + ); + + user.set('localeIdentifier', 'en-US'); + + await user.save(); + + expect(ParseCoreData().sessionId, equals('r:stableSession')); + }, + ); + + test( + 'update() adopts a new sessionToken from the response. save() and ' + 'update() are independent entry points, both need to install the ' + 'token to keep the active session in sync with the server', + () async { + ParseCoreData().setSessionId('r:priorSession'); + + final ParseUser user = ParseUser(null, null, null, client: client); + user.fromJson({ + keyVarObjectId: userObjectId, + keyVarSessionToken: 'r:priorSession', + keyVarUsername: 'alice@example.com', + }); + + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + keyVarSessionToken: 'r:freshSession', + }), + ), + ); + + user.password = 'hunter2'; + + final ParseResponse response = await user.update(); + + expect(response.success, isTrue); + expect(ParseCoreData().sessionId, equals('r:freshSession')); + }, + ); + }); +} From 9a3d64b6f2debd730889640d93c0e7ba125f12fe Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Sun, 17 May 2026 18:34:06 -0500 Subject: [PATCH 2/3] test: Restore ParseCoreData session state between tests Tests in this group mutate the singleton ParseCoreData().sessionId and were not restoring it, leaking state across cases. Capture the prior session in setUp() and restore it in tearDown() so each test starts from a known baseline. Addresses CodeRabbit nit on PR #1139. --- .../objects/parse_user/parse_user_session_token_test.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart b/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart index c221e1b6a..732e147e7 100644 --- a/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart +++ b/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart @@ -14,6 +14,7 @@ void main() { group('ParseUser save()/update() — sessionToken adoption from response', () { late MockParseClient client; + String? previousSessionId; const String userObjectId = 'sess123'; final String putPath = Uri.parse( @@ -22,6 +23,11 @@ void main() { setUp(() { client = MockParseClient(); + previousSessionId = ParseCoreData().sessionId; + }); + + tearDown(() { + ParseCoreData().sessionId = previousSessionId; }); test( From 5768574cd14a43ff3ad2e3c46978f5326aca50d4 Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Sun, 17 May 2026 18:43:53 -0500 Subject: [PATCH 3/3] style: Format session-token test for Dart 3.10 CI runs `dart format --set-exit-if-changed` on Dart 3.10 stable; the test file was written under a newer formatter style and tripped that check. Apply the Dart 3.10 conventions (inline test name, no trailing comma in test() argument list). --- .../parse_user_session_token_test.dart | 225 +++++++++--------- 1 file changed, 108 insertions(+), 117 deletions(-) diff --git a/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart b/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart index 732e147e7..51ac23e64 100644 --- a/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart +++ b/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart @@ -30,122 +30,113 @@ void main() { ParseCoreData().sessionId = previousSessionId; }); - test( - 'when a save() response carries a sessionToken different from the ' - 'one sent, the SDK installs it as the global session token. Parse ' - 'Server mints a fresh session when password is set on an existing ' - '_User; the prior session is destroyed server-side, so the global ' - 'session must be updated or subsequent requests fail with ' - 'invalidSessionToken', - () async { - ParseCoreData().setSessionId('r:priorSession'); - - final ParseUser user = ParseUser(null, null, null, client: client); - user.fromJson({ - keyVarObjectId: userObjectId, - keyVarSessionToken: 'r:priorSession', - keyVarUsername: 'alice@example.com', - }); - - when( - client.put( - putPath, - options: anyNamed('options'), - data: anyNamed('data'), - ), - ).thenAnswer( - (_) async => ParseNetworkResponse( - statusCode: 200, - data: jsonEncode({ - keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', - keyVarSessionToken: 'r:freshSession', - }), - ), - ); - - user.password = 'hunter2'; - - final ParseResponse response = await user.save(); - - expect(response.success, isTrue); - expect(ParseCoreData().sessionId, equals('r:freshSession')); - }, - ); - - test( - 'when a save() response does NOT carry a sessionToken, the global ' - 'session is left untouched. the previously-cached local sessionToken ' - 'on the user object must not be re-promoted to global state', - () async { - ParseCoreData().setSessionId('r:stableSession'); - - final ParseUser user = ParseUser(null, null, null, client: client); - user.fromJson({ - keyVarObjectId: userObjectId, - keyVarSessionToken: 'r:stableSession', - keyVarUsername: 'alice@example.com', - }); - - when( - client.put( - putPath, - options: anyNamed('options'), - data: anyNamed('data'), - ), - ).thenAnswer( - (_) async => ParseNetworkResponse( - statusCode: 200, - data: jsonEncode({ - keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', - }), - ), - ); - - user.set('localeIdentifier', 'en-US'); - - await user.save(); - - expect(ParseCoreData().sessionId, equals('r:stableSession')); - }, - ); - - test( - 'update() adopts a new sessionToken from the response. save() and ' - 'update() are independent entry points, both need to install the ' - 'token to keep the active session in sync with the server', - () async { - ParseCoreData().setSessionId('r:priorSession'); - - final ParseUser user = ParseUser(null, null, null, client: client); - user.fromJson({ - keyVarObjectId: userObjectId, - keyVarSessionToken: 'r:priorSession', - keyVarUsername: 'alice@example.com', - }); - - when( - client.put( - putPath, - options: anyNamed('options'), - data: anyNamed('data'), - ), - ).thenAnswer( - (_) async => ParseNetworkResponse( - statusCode: 200, - data: jsonEncode({ - keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', - keyVarSessionToken: 'r:freshSession', - }), - ), - ); - - user.password = 'hunter2'; - - final ParseResponse response = await user.update(); - - expect(response.success, isTrue); - expect(ParseCoreData().sessionId, equals('r:freshSession')); - }, - ); + test('when a save() response carries a sessionToken different from the ' + 'one sent, the SDK installs it as the global session token. Parse ' + 'Server mints a fresh session when password is set on an existing ' + '_User; the prior session is destroyed server-side, so the global ' + 'session must be updated or subsequent requests fail with ' + 'invalidSessionToken', () async { + ParseCoreData().setSessionId('r:priorSession'); + + final ParseUser user = ParseUser(null, null, null, client: client); + user.fromJson({ + keyVarObjectId: userObjectId, + keyVarSessionToken: 'r:priorSession', + keyVarUsername: 'alice@example.com', + }); + + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + keyVarSessionToken: 'r:freshSession', + }), + ), + ); + + user.password = 'hunter2'; + + final ParseResponse response = await user.save(); + + expect(response.success, isTrue); + expect(ParseCoreData().sessionId, equals('r:freshSession')); + }); + + test('when a save() response does NOT carry a sessionToken, the global ' + 'session is left untouched. the previously-cached local sessionToken ' + 'on the user object must not be re-promoted to global state', () async { + ParseCoreData().setSessionId('r:stableSession'); + + final ParseUser user = ParseUser(null, null, null, client: client); + user.fromJson({ + keyVarObjectId: userObjectId, + keyVarSessionToken: 'r:stableSession', + keyVarUsername: 'alice@example.com', + }); + + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + }), + ), + ); + + user.set('localeIdentifier', 'en-US'); + + await user.save(); + + expect(ParseCoreData().sessionId, equals('r:stableSession')); + }); + + test('update() adopts a new sessionToken from the response. save() and ' + 'update() are independent entry points, both need to install the ' + 'token to keep the active session in sync with the server', () async { + ParseCoreData().setSessionId('r:priorSession'); + + final ParseUser user = ParseUser(null, null, null, client: client); + user.fromJson({ + keyVarObjectId: userObjectId, + keyVarSessionToken: 'r:priorSession', + keyVarUsername: 'alice@example.com', + }); + + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + keyVarSessionToken: 'r:freshSession', + }), + ), + ); + + user.password = 'hunter2'; + + final ParseResponse response = await user.update(); + + expect(response.success, isTrue); + expect(ParseCoreData().sessionId, equals('r:freshSession')); + }); }); }