From 0860324e8c1e3c47d083f99c424c447606096605 Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Tue, 28 Apr 2026 08:52:36 -0500 Subject: [PATCH 1/9] fix: Reconcile local authData on anonymous-to-email conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting username on a persisted anonymous user and calling save() now unlinks the anonymous provider server-side and leaves the local authData consistent with the cleaned server record — without requiring a follow-up GET. Previously, the save's PUT response carried only the dirty fields written by the client (no authData), so the additive response merge in _handleSingleResult could not reconcile the local authData copy. Apps had to follow up with GET /users/me to refresh local state. This mirrors iOS PFUser's stripAnonymity + cleanUpAuthData pattern. No public API changes; the new helpers are private. --- packages/dart/lib/src/objects/parse_user.dart | 49 +++- .../parse_user_anonymous_link_test.dart | 273 ++++++++++++++++++ 2 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 packages/dart/test/src/objects/parse_user/parse_user_anonymous_link_test.dart diff --git a/packages/dart/lib/src/objects/parse_user.dart b/packages/dart/lib/src/objects/parse_user.dart index c89a5e87d..2ec8ba2e7 100644 --- a/packages/dart/lib/src/objects/parse_user.dart +++ b/packages/dart/lib/src/objects/parse_user.dart @@ -43,6 +43,7 @@ class ParseUser extends ParseObject implements ParseCloneable { static const String keyUsername = 'username'; static const String keyEmailAddress = 'email'; static const String path = '$keyEndPointClasses$keyClassUser'; + static const String _keyAuthAnonymous = 'anonymous'; String? _password; @@ -70,7 +71,10 @@ class ParseUser extends ParseObject implements ParseCloneable { String? get username => super.get(keyVarUsername); - set username(String? username) => set(keyVarUsername, username); + set username(String? username) { + _stripAnonymity(); + set(keyVarUsername, username); + } String? get emailAddress => super.get(keyVarEmail); @@ -282,7 +286,13 @@ class ParseUser extends ParseObject implements ParseCloneable { } } - /// Logs in a user anonymously + /// Logs in a user anonymously. + /// + /// To convert the resulting anonymous user into a permanent account, + /// set `username` and `password` on the [ParseUser] and call [save]. + /// The anonymous provider is unlinked server-side and the local + /// `authData` is reconciled automatically. + /// /// Set [doNotSendInstallationID] to 'true' in order to prevent the SDK from sending the installationID to the Server. /// This option is especially useful if you are running you application on web and you don't have permission to add 'X-Parse-Installation-Id' as an allowed header on your parse-server. Future loginAnonymous({ @@ -495,6 +505,7 @@ class ParseUser extends ParseObject implements ParseCloneable { } else { final ParseResponse response = await super.save(); if (response.success) { + _cleanUpAuthData(); await _onResponseSuccess(); } return response; @@ -508,6 +519,7 @@ class ParseUser extends ParseObject implements ParseCloneable { } else { final ParseResponse response = await super.update(); if (response.success) { + _cleanUpAuthData(); await _onResponseSuccess(); } return response; @@ -518,6 +530,39 @@ class ParseUser extends ParseObject implements ParseCloneable { await saveInStorage(keyParseStoreUser); } + void _stripAnonymity() { + final Map? authData = + _objectData[keyVarAuthData] as Map?; + if (authData == null || !authData.containsKey(_keyAuthAnonymous)) { + return; + } + if (objectId == null) { + authData.remove(_keyAuthAnonymous); + } else { + authData[_keyAuthAnonymous] = null; + } + _unsavedChanges[keyVarAuthData] = authData; + } + + void _cleanUpAuthData() { + final Map? authData = + _objectData[keyVarAuthData] as Map?; + if (authData != null) { + authData.removeWhere((_, dynamic value) => value == null); + if (authData.isEmpty) { + _objectData.remove(keyVarAuthData); + } + } + final Map? dirty = + _unsavedChanges[keyVarAuthData] as Map?; + if (dirty != null) { + dirty.removeWhere((_, dynamic value) => value == null); + if (dirty.isEmpty) { + _unsavedChanges.remove(keyVarAuthData); + } + } + } + /// Removes a user from Parse Server locally and online Future destroy() async { if (objectId != null) { diff --git a/packages/dart/test/src/objects/parse_user/parse_user_anonymous_link_test.dart b/packages/dart/test/src/objects/parse_user/parse_user_anonymous_link_test.dart new file mode 100644 index 000000000..7c41d6532 --- /dev/null +++ b/packages/dart/test/src/objects/parse_user/parse_user_anonymous_link_test.dart @@ -0,0 +1,273 @@ +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 anonymous → email/password conversion', () { + late MockParseClient client; + + const String userObjectId = 'abc123XYZ'; + const String anonymousId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + final String putPath = Uri.parse( + '$serverUrl$keyEndPointClasses$keyClassUser/$userObjectId', + ).toString(); + + setUp(() { + client = MockParseClient(); + }); + + ParseUser anonymousUserWithObjectId({ + Map? extraAuthData, + }) { + final ParseUser user = ParseUser(null, null, null, client: client); + final Map authData = { + 'anonymous': {'id': anonymousId}, + }; + if (extraAuthData != null) { + authData.addAll(extraAuthData); + } + user.fromJson({ + keyVarObjectId: userObjectId, + keyVarSessionToken: 'r:abcdef0123456789', + keyVarAuthData: authData, + keyVarCreatedAt: '2026-04-28T12:00:00.000Z', + keyVarUpdatedAt: '2026-04-28T12:00:00.000Z', + }); + return user; + } + + test( + 'setting username on a persisted anonymous user marks the anonymous ' + 'authData entry for unlinking by setting its value to null. the null ' + 'value is the unlink signal Parse Server interprets on the next save', + () { + final ParseUser user = anonymousUserWithObjectId(); + + user.username = 'alice@example.com'; + + expect(user.authData, isNotNull); + expect(user.authData!.containsKey('anonymous'), isTrue); + expect(user.authData!['anonymous'], isNull); + }, + ); + + test( + 'after a successful save, the local authData no longer contains the ' + 'anonymous entry. this matches the post-cleanup server record without ' + 'requiring a follow-up GET — the PUT response from Parse Server omits ' + 'authData even after the beforeSave trigger strips the anonymous entry', + () async { + final ParseUser user = anonymousUserWithObjectId(); + + // Server response only carries the dirty fields it processed; no + // authData echoed back. This is the realistic Parse Server shape. + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUsername: 'alice@example.com', + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + keyVarSessionToken: 'r:newtoken', + }), + ), + ); + + user.username = 'alice@example.com'; + user.password = 'hunter2'; + + final ParseResponse response = await user.save(); + + expect(response.success, isTrue); + final Map? cleaned = user.authData; + expect( + cleaned == null || !cleaned.containsKey('anonymous'), + isTrue, + reason: + 'authData.anonymous should be removed locally after a ' + 'successful conversion save', + ); + }, + ); + + test( + 'the PUT body for an anonymous-conversion save carries ' + 'authData.anonymous = null. this is the wire-level unlink signal that ' + 'lets Parse Server clean the server-side record on the same round-trip', + () async { + final ParseUser user = anonymousUserWithObjectId(); + + String? capturedBody; + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer((Invocation invocation) async { + capturedBody = + invocation.namedArguments[const Symbol('data')] as String?; + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + }), + ); + }); + + user.username = 'alice@example.com'; + user.password = 'hunter2'; + + await user.save(); + + expect(capturedBody, isNotNull); + final Map decoded = + jsonDecode(capturedBody!) as Map; + expect(decoded[keyVarAuthData], isA()); + final Map sentAuthData = + (decoded[keyVarAuthData] as Map).cast(); + expect(sentAuthData.containsKey('anonymous'), isTrue); + expect(sentAuthData['anonymous'], isNull); + }, + ); + + test( + 'non-anonymous authData entries (e.g. facebook, google) survive the ' + 'conversion. only the anonymous null marker is dropped on cleanup', + () async { + final ParseUser user = anonymousUserWithObjectId( + extraAuthData: { + 'facebook': { + 'id': 'fb-12345', + 'access_token': 'tok', + }, + }, + ); + + 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.username = 'alice@example.com'; + + await user.save(); + + expect(user.authData, isNotNull); + expect(user.authData!.containsKey('anonymous'), isFalse); + expect(user.authData!.containsKey('facebook'), isTrue); + expect(user.authData!['facebook'], isNotNull); + }, + ); + + test( + 'on a lazy (no objectId) anonymous user, setting username drops the ' + 'anonymous entry locally without leaving a null marker. unpersisted ' + 'users have nothing to unlink server-side, so no marker is needed', + () { + final ParseUser user = ParseUser(null, null, null, client: client); + user.fromJson({ + keyVarAuthData: { + 'anonymous': {'id': anonymousId}, + }, + }); + + user.username = 'alice@example.com'; + + final Map? cleaned = user.authData; + expect( + cleaned == null || !cleaned.containsKey('anonymous'), + isTrue, + reason: + 'unpersisted anonymous user should drop the entry without ' + 'leaving a null marker', + ); + }, + ); + }); + + group('ParseUser save() regression — non-anonymous users', () { + late MockParseClient client; + + const String userObjectId = 'reg123'; + final String putPath = Uri.parse( + '$serverUrl$keyEndPointClasses$keyClassUser/$userObjectId', + ).toString(); + + setUp(() { + client = MockParseClient(); + }); + + test( + 'setting username on a non-anonymous user does not synthesize authData ' + 'into the request body. only the anonymous-conversion case should ' + 'inject the null marker', + () async { + final ParseUser user = ParseUser(null, null, null, client: client); + user.fromJson({ + keyVarObjectId: userObjectId, + keyVarSessionToken: 'r:xyz', + keyVarUsername: 'old@example.com', + }); + + String? capturedBody; + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer((Invocation invocation) async { + capturedBody = + invocation.namedArguments[const Symbol('data')] as String?; + return ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + }), + ); + }); + + user.username = 'new@example.com'; + + final ParseResponse response = await user.save(); + + expect(response.success, isTrue); + expect(capturedBody, isNotNull); + final Map decoded = + jsonDecode(capturedBody!) as Map; + expect( + decoded.containsKey(keyVarAuthData), + isFalse, + reason: + 'a regular username update on a non-anonymous user should not ' + 'synthesize authData in the request body', + ); + }, + ); + }); +} From 314bfc8568ea16e2d23bc6b85e0a7a0eeaa88845 Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Tue, 28 Apr 2026 09:43:40 -0500 Subject: [PATCH 2/9] test: Cover ParseUser.update() in anonymous-link cleanup tests save() and update() both run _cleanUpAuthData() in their success branch but were patched independently. Add a parallel test that invokes update() instead of save() so a future regression in either path is caught. Also tightened test descriptions to focus on client-side behavior. --- .../parse_user_anonymous_link_test.dart | 162 ++++++++++-------- 1 file changed, 95 insertions(+), 67 deletions(-) diff --git a/packages/dart/test/src/objects/parse_user/parse_user_anonymous_link_test.dart b/packages/dart/test/src/objects/parse_user/parse_user_anonymous_link_test.dart index 7c41d6532..3f604f9f7 100644 --- a/packages/dart/test/src/objects/parse_user/parse_user_anonymous_link_test.dart +++ b/packages/dart/test/src/objects/parse_user/parse_user_anonymous_link_test.dart @@ -25,9 +25,7 @@ void main() { client = MockParseClient(); }); - ParseUser anonymousUserWithObjectId({ - Map? extraAuthData, - }) { + ParseUser anonymousUserWithObjectId({Map? extraAuthData}) { final ParseUser user = ParseUser(null, null, null, client: client); final Map authData = { 'anonymous': {'id': anonymousId}, @@ -60,49 +58,82 @@ void main() { }, ); - test( - 'after a successful save, the local authData no longer contains the ' - 'anonymous entry. this matches the post-cleanup server record without ' - 'requiring a follow-up GET — the PUT response from Parse Server omits ' - 'authData even after the beforeSave trigger strips the anonymous entry', - () async { - final ParseUser user = anonymousUserWithObjectId(); - - // Server response only carries the dirty fields it processed; no - // authData echoed back. This is the realistic Parse Server shape. - when( - client.put( - putPath, - options: anyNamed('options'), - data: anyNamed('data'), - ), - ).thenAnswer( - (_) async => ParseNetworkResponse( - statusCode: 200, - data: jsonEncode({ - keyVarUsername: 'alice@example.com', - keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', - keyVarSessionToken: 'r:newtoken', - }), - ), - ); - - user.username = 'alice@example.com'; - user.password = 'hunter2'; - - final ParseResponse response = await user.save(); + test('after a successful save(), the local authData no longer contains ' + 'the anonymous entry. the PUT response carries only the fields the ' + 'client wrote, so the post-save cleanup is what reconciles local ' + 'state with the server-side unlink', () async { + final ParseUser user = anonymousUserWithObjectId(); + + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUsername: 'alice@example.com', + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + keyVarSessionToken: 'r:newtoken', + }), + ), + ); + + user.username = 'alice@example.com'; + user.password = 'hunter2'; + + final ParseResponse response = await user.save(); + + expect(response.success, isTrue); + final Map? cleaned = user.authData; + expect( + cleaned == null || !cleaned.containsKey('anonymous'), + isTrue, + reason: + 'authData.anonymous should be removed locally after a ' + 'successful conversion save', + ); + }); - expect(response.success, isTrue); - final Map? cleaned = user.authData; - expect( - cleaned == null || !cleaned.containsKey('anonymous'), - isTrue, - reason: - 'authData.anonymous should be removed locally after a ' - 'successful conversion save', - ); - }, - ); + test('after a successful update(), the local authData no longer contains ' + 'the anonymous entry. update() and save() both run the post-save ' + 'cleanup, so each path needs independent coverage', () async { + final ParseUser user = anonymousUserWithObjectId(); + + when( + client.put( + putPath, + options: anyNamed('options'), + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => ParseNetworkResponse( + statusCode: 200, + data: jsonEncode({ + keyVarUsername: 'alice@example.com', + keyVarUpdatedAt: '2026-04-28T12:00:01.000Z', + keyVarSessionToken: 'r:newtoken', + }), + ), + ); + + user.username = 'alice@example.com'; + user.password = 'hunter2'; + + final ParseResponse response = await user.update(); + + expect(response.success, isTrue); + final Map? cleaned = user.authData; + expect( + cleaned == null || !cleaned.containsKey('anonymous'), + isTrue, + reason: + 'authData.anonymous should be removed locally after a ' + 'successful conversion update', + ); + }); test( 'the PUT body for an anonymous-conversion save carries ' @@ -184,30 +215,27 @@ void main() { }, ); - test( - 'on a lazy (no objectId) anonymous user, setting username drops the ' - 'anonymous entry locally without leaving a null marker. unpersisted ' - 'users have nothing to unlink server-side, so no marker is needed', - () { - final ParseUser user = ParseUser(null, null, null, client: client); - user.fromJson({ - keyVarAuthData: { - 'anonymous': {'id': anonymousId}, - }, - }); + test('on a lazy (no objectId) anonymous user, setting username drops the ' + 'anonymous entry locally without leaving a null marker. unpersisted ' + 'users have nothing to unlink server-side, so no marker is needed', () { + final ParseUser user = ParseUser(null, null, null, client: client); + user.fromJson({ + keyVarAuthData: { + 'anonymous': {'id': anonymousId}, + }, + }); - user.username = 'alice@example.com'; + user.username = 'alice@example.com'; - final Map? cleaned = user.authData; - expect( - cleaned == null || !cleaned.containsKey('anonymous'), - isTrue, - reason: - 'unpersisted anonymous user should drop the entry without ' - 'leaving a null marker', - ); - }, - ); + final Map? cleaned = user.authData; + expect( + cleaned == null || !cleaned.containsKey('anonymous'), + isTrue, + reason: + 'unpersisted anonymous user should drop the entry without ' + 'leaving a null marker', + ); + }); }); group('ParseUser save() regression — non-anonymous users', () { From 64ec48a118f4008d1e223b21ce14bfc4159e65ee Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Tue, 28 Apr 2026 09:43:43 -0500 Subject: [PATCH 3/9] fix: Drop authData from unsaved changes when only anonymous was present On the lazy (no objectId) path of _stripAnonymity, removing the anonymous provider could leave an empty map in _unsavedChanges under keyVarAuthData. The empty map then serialized into the POST body via toJson(forApiRQ: true) on signUp, sending wire noise the SDK shouldn't emit. Mirrors the isEmpty handling already present in _cleanUpAuthData. --- packages/dart/lib/src/objects/parse_user.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/dart/lib/src/objects/parse_user.dart b/packages/dart/lib/src/objects/parse_user.dart index 2ec8ba2e7..8550ff9a2 100644 --- a/packages/dart/lib/src/objects/parse_user.dart +++ b/packages/dart/lib/src/objects/parse_user.dart @@ -541,7 +541,11 @@ class ParseUser extends ParseObject implements ParseCloneable { } else { authData[_keyAuthAnonymous] = null; } - _unsavedChanges[keyVarAuthData] = authData; + if (authData.isEmpty) { + _unsavedChanges.remove(keyVarAuthData); + } else { + _unsavedChanges[keyVarAuthData] = authData; + } } void _cleanUpAuthData() { From 83244367f907a24e3c7dd1b71cc8fe366a3cd987 Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Sat, 16 May 2026 17:13:48 -0500 Subject: [PATCH 4/9] 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 8550ff9a2..7d732f656 100644 --- a/packages/dart/lib/src/objects/parse_user.dart +++ b/packages/dart/lib/src/objects/parse_user.dart @@ -503,8 +503,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); _cleanUpAuthData(); await _onResponseSuccess(); } @@ -517,8 +519,10 @@ 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); _cleanUpAuthData(); await _onResponseSuccess(); } @@ -526,6 +530,19 @@ class ParseUser extends ParseObject implements ParseCloneable { } } + /// Adopt a new sessionToken from a save/update response. Parse Server + /// mints a fresh session when `password` is set on an existing _User + /// (revokeSessionOnPasswordReset, default true since 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 5b2cd09e3b7cd3273c4908534beeabd34c30a4ba Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Sat, 16 May 2026 18:03:12 -0500 Subject: [PATCH 5/9] feat: Send X-Parse-Installation-Id on all HTTP requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match iOS PFURLSessionCommandRunner behaviour, which attaches the install ID header to every outgoing request. Previously, only the three user-creation methods (signUp, login, loginAnonymous, loginWith) built the header inline; every other call — including PUT save on an existing user — went out without it. Without the install ID on writes, parse-server cannot bind the _Session row it mints during an authData/password change to the device's installation, and destroyDuplicatedSessions skips cleanup of prior sessions on the same install. Empirically this leaves one orphan _Session row per anonymous-to-password link. Changes: * Add `sendInstallationId` flag to `ParseNetworkOptions` (null/true → attach, false → suppress) so the existing `doNotSendInstallationID` parameter on user methods continues to opt out cleanly. * Hoist a shared `buildHeaders` helper onto the abstract `ParseClient` so both `ParseHTTPClient` and `ParseDioClient` route through one implementation. `@protected`; no public API surface change. * Both clients call `await buildHeaders(options)` from each of get, put, post, postBytes, getBytes, and delete. * `ParseUser.signUp/login/loginAnonymous/_loginWith` drop their inline `_getInstallationId()` + conditional header building and pass `sendInstallationId: !doNotSendInstallationID` through `ParseNetworkOptions` instead. Removes the unused `_getInstallationId` helper. Tests: * `parse_client_test.dart` (new): 4 unit tests for `buildHeaders` — default attaches, sendInstallationId:false suppresses, caller- supplied header preserved, custom-header + auto install ID coexist. * `parse_dio_client_test.dart`: 1 integration test via a custom `HttpClientAdapter` that captures outgoing request headers, proves `buildHeaders` output reaches dio's wire. * `parse_http_client_test.dart`: copy-pasted "ParseDioClient" labels corrected to "ParseHTTPClient". --- packages/dart/lib/src/network/options.dart | 7 +- .../dart/lib/src/network/parse_client.dart | 27 ++++ .../lib/src/network/parse_dio_client.dart | 18 ++- .../lib/src/network/parse_http_client.dart | 18 ++- packages/dart/lib/src/objects/parse_user.dart | 38 +---- .../test/src/network/parse_client_test.dart | 139 ++++++++++++++++++ .../src/network/parse_dio_client_test.dart | 50 +++++++ .../src/network/parse_http_client_test.dart | 10 +- 8 files changed, 259 insertions(+), 48 deletions(-) create mode 100644 packages/dart/test/src/network/parse_client_test.dart diff --git a/packages/dart/lib/src/network/options.dart b/packages/dart/lib/src/network/options.dart index 5b07d7303..4b6bb1c62 100644 --- a/packages/dart/lib/src/network/options.dart +++ b/packages/dart/lib/src/network/options.dart @@ -1,9 +1,14 @@ part of '../../parse_server_sdk.dart'; class ParseNetworkOptions { - ParseNetworkOptions({this.headers}); + ParseNetworkOptions({this.headers, this.sendInstallationId}); final Map? headers; + + /// When `false`, the client suppresses the `X-Parse-Installation-Id` + /// header for this request. `null` (the default) lets the client attach + /// the header — matching iOS PFURLSessionCommandRunner behaviour. + final bool? sendInstallationId; // final ParseNetworkResponseType responseType; } diff --git a/packages/dart/lib/src/network/parse_client.dart b/packages/dart/lib/src/network/parse_client.dart index 2104fc20a..a013cd5db 100644 --- a/packages/dart/lib/src/network/parse_client.dart +++ b/packages/dart/lib/src/network/parse_client.dart @@ -69,6 +69,33 @@ abstract class ParseClient { @Deprecated("Use ParseCoreData() instead.") ParseCoreData get data => ParseCoreData(); + + /// Returns `options.headers` with `X-Parse-Installation-Id` attached unless + /// the caller opted out via `ParseNetworkOptions.sendInstallationId = false` + /// or the header is already set. Installation lookup failures fall through + /// silently — a network call should not fail because the install ID could + /// not be read. + @protected + Future?> buildHeaders( + ParseNetworkOptions? options, + ) async { + if (options?.sendInstallationId == false) return options?.headers; + if (options?.headers?[keyHeaderInstallationId] != null) { + return options?.headers; + } + String? installationId; + try { + installationId = + (await ParseInstallation.currentInstallation()).installationId; + } catch (_) { + return options?.headers; + } + if (installationId == null) return options?.headers; + return { + ...?options?.headers, + keyHeaderInstallationId: installationId, + }; + } } /// Callback to listen the progress for sending/receiving data. diff --git a/packages/dart/lib/src/network/parse_dio_client.dart b/packages/dart/lib/src/network/parse_dio_client.dart index fb6072bee..417ba5a37 100644 --- a/packages/dart/lib/src/network/parse_dio_client.dart +++ b/packages/dart/lib/src/network/parse_dio_client.dart @@ -47,12 +47,13 @@ class ParseDioClient extends ParseClient { ParseNetworkOptions? options, ProgressCallback? onReceiveProgress, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( operation: () async { try { final dio.Response dioResponse = await _client.get( path, - options: _Options(headers: options?.headers), + options: _Options(headers: headers), ); return ParseNetworkResponse( @@ -76,6 +77,7 @@ class ParseDioClient extends ParseClient { ProgressCallback? onReceiveProgress, dynamic cancelToken, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( operation: () async { try { @@ -85,7 +87,7 @@ class ParseDioClient extends ParseClient { cancelToken: cancelToken, onReceiveProgress: onReceiveProgress, options: _Options( - headers: options?.headers, + headers: headers, responseType: dio.ResponseType.bytes, ), ); @@ -116,6 +118,7 @@ class ParseDioClient extends ParseClient { String? data, ParseNetworkOptions? options, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( isWriteOperation: true, operation: () async { @@ -123,7 +126,7 @@ class ParseDioClient extends ParseClient { final dio.Response dioResponse = await _client.put( path, data: data, - options: _Options(headers: options?.headers), + options: _Options(headers: headers), ); return ParseNetworkResponse( @@ -146,6 +149,7 @@ class ParseDioClient extends ParseClient { String? data, ParseNetworkOptions? options, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( isWriteOperation: true, operation: () async { @@ -153,7 +157,7 @@ class ParseDioClient extends ParseClient { final dio.Response dioResponse = await _client.post( path, data: data, - options: _Options(headers: options?.headers), + options: _Options(headers: headers), ); return ParseNetworkResponse( @@ -178,6 +182,7 @@ class ParseDioClient extends ParseClient { ProgressCallback? onSendProgress, dynamic cancelToken, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( isWriteOperation: true, operation: () async { @@ -186,7 +191,7 @@ class ParseDioClient extends ParseClient { path, data: data, cancelToken: cancelToken, - options: _Options(headers: options?.headers), + options: _Options(headers: headers), onSendProgress: onSendProgress, ); @@ -235,12 +240,13 @@ class ParseDioClient extends ParseClient { String path, { ParseNetworkOptions? options, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( operation: () async { try { final dio.Response dioResponse = await _client.delete( path, - options: _Options(headers: options?.headers), + options: _Options(headers: headers), ); return ParseNetworkResponse( diff --git a/packages/dart/lib/src/network/parse_http_client.dart b/packages/dart/lib/src/network/parse_http_client.dart index d729733af..c88a78a07 100644 --- a/packages/dart/lib/src/network/parse_http_client.dart +++ b/packages/dart/lib/src/network/parse_http_client.dart @@ -47,12 +47,13 @@ class ParseHTTPClient extends ParseClient { ParseNetworkOptions? options, ProgressCallback? onReceiveProgress, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( operation: () async { try { final http.Response response = await _client.get( Uri.parse(path), - headers: options?.headers, + headers: headers, ); return ParseNetworkResponse( data: response.body, @@ -75,12 +76,13 @@ class ParseHTTPClient extends ParseClient { ProgressCallback? onReceiveProgress, dynamic cancelToken, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( operation: () async { try { final http.Response response = await _client.get( Uri.parse(path), - headers: options?.headers, + headers: headers, ); return ParseNetworkByteResponse( bytes: response.bodyBytes, @@ -102,6 +104,7 @@ class ParseHTTPClient extends ParseClient { String? data, ParseNetworkOptions? options, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( isWriteOperation: true, operation: () async { @@ -109,7 +112,7 @@ class ParseHTTPClient extends ParseClient { final http.Response response = await _client.put( Uri.parse(path), body: data, - headers: options?.headers, + headers: headers, ); return ParseNetworkResponse( data: response.body, @@ -131,6 +134,7 @@ class ParseHTTPClient extends ParseClient { String? data, ParseNetworkOptions? options, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( isWriteOperation: true, operation: () async { @@ -138,7 +142,7 @@ class ParseHTTPClient extends ParseClient { final http.Response response = await _client.post( Uri.parse(path), body: data, - headers: options?.headers, + headers: headers, ); return ParseNetworkResponse( data: response.body, @@ -162,6 +166,7 @@ class ParseHTTPClient extends ParseClient { ProgressCallback? onSendProgress, dynamic cancelToken, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( isWriteOperation: true, operation: () async { @@ -174,7 +179,7 @@ class ParseHTTPClient extends ParseClient { (List previous, List element) => previous..addAll(element), ), - headers: options?.headers, + headers: headers, ); return ParseNetworkResponse( data: response.body, @@ -195,12 +200,13 @@ class ParseHTTPClient extends ParseClient { String path, { ParseNetworkOptions? options, }) async { + final Map? headers = await buildHeaders(options); return executeWithRetry( operation: () async { try { final http.Response response = await _client.delete( Uri.parse(path), - headers: options?.headers, + headers: headers, ); return ParseNetworkResponse( data: response.body, diff --git a/packages/dart/lib/src/objects/parse_user.dart b/packages/dart/lib/src/objects/parse_user.dart index 7d732f656..eac47f192 100644 --- a/packages/dart/lib/src/objects/parse_user.dart +++ b/packages/dart/lib/src/objects/parse_user.dart @@ -220,15 +220,11 @@ class ParseUser extends ParseObject implements ParseCloneable { final Uri url = getSanitisedUri(_client, path); final String body = json.encode(toJson(forApiRQ: true)); _saveChanges(); - final String? installationId = await _getInstallationId(); final ParseNetworkResponse response = await _client.post( url.toString(), options: ParseNetworkOptions( - headers: { - keyHeaderRevocableSession: '1', - if (installationId != null && !doNotSendInstallationID) - keyHeaderInstallationId: installationId, - }, + headers: {keyHeaderRevocableSession: '1'}, + sendInstallationId: !doNotSendInstallationID, ), data: body, ); @@ -259,18 +255,14 @@ class ParseUser extends ParseObject implements ParseCloneable { keyVarUsername: username!, keyVarPassword: password!, }; - final String? installationId = await _getInstallationId(); final Uri url = getSanitisedUri(_client, keyEndPointLogin); _saveChanges(); final ParseNetworkResponse response = await _client.post( url.toString(), data: jsonEncode(queryParams), options: ParseNetworkOptions( - headers: { - keyHeaderRevocableSession: '1', - if (installationId != null && !doNotSendInstallationID) - keyHeaderInstallationId: installationId, - }, + headers: {keyHeaderRevocableSession: '1'}, + sendInstallationId: !doNotSendInstallationID, ), ); @@ -302,16 +294,12 @@ class ParseUser extends ParseObject implements ParseCloneable { try { final Uri url = getSanitisedUri(_client, keyEndPointUsers); const Uuid uuid = Uuid(); - final String? installationId = await _getInstallationId(); final ParseNetworkResponse response = await _client.post( url.toString(), options: ParseNetworkOptions( - headers: { - keyHeaderRevocableSession: '1', - if (installationId != null && !doNotSendInstallationID) - keyHeaderInstallationId: installationId, - }, + headers: {keyHeaderRevocableSession: '1'}, + sendInstallationId: !doNotSendInstallationID, ), data: jsonEncode({ 'authData': { @@ -366,17 +354,13 @@ class ParseUser extends ParseObject implements ParseCloneable { }) async { try { final Uri url = getSanitisedUri(_client, keyEndPointUsers); - final String? installationId = await _getInstallationId(); final Map body = toJson(forApiRQ: true); body['authData'] = {provider: authData}; final ParseNetworkResponse response = await _client.post( url.toString(), options: ParseNetworkOptions( - headers: { - keyHeaderRevocableSession: '1', - if (installationId != null && !doNotSendInstallationID) - keyHeaderInstallationId: installationId, - }, + headers: {keyHeaderRevocableSession: '1'}, + sendInstallationId: !doNotSendInstallationID, ), data: jsonEncode(body), ); @@ -696,10 +680,4 @@ class ParseUser extends ParseObject implements ParseCloneable { static ParseUser _getEmptyUser() => ParseCoreData.instance.createParseUser(null, null, null); - - static Future _getInstallationId() async { - final ParseInstallation parseInstallation = - await ParseInstallation.currentInstallation(); - return parseInstallation.installationId; - } } diff --git a/packages/dart/test/src/network/parse_client_test.dart b/packages/dart/test/src/network/parse_client_test.dart new file mode 100644 index 000000000..83a0c1ce2 --- /dev/null +++ b/packages/dart/test/src/network/parse_client_test.dart @@ -0,0 +1,139 @@ +import 'package:parse_server_sdk/parse_server_sdk.dart'; +import 'package:test/test.dart'; + +import '../../test_utils.dart'; + +/// Minimal concrete subclass for exercising methods on the abstract +/// [ParseClient]. All HTTP methods throw — the test suite only targets the +/// inherited helpers (`buildHeaders`) and never dispatches a real request. +class _StubParseClient extends ParseClient { + @override + Future get( + String path, { + ParseNetworkOptions? options, + ProgressCallback? onReceiveProgress, + }) => throw UnimplementedError(); + + @override + Future put( + String path, { + String? data, + ParseNetworkOptions? options, + }) => throw UnimplementedError(); + + @override + Future post( + String path, { + String? data, + ParseNetworkOptions? options, + }) => throw UnimplementedError(); + + @override + Future postBytes( + String path, { + Stream>? data, + ParseNetworkOptions? options, + ProgressCallback? onSendProgress, + dynamic cancelToken, + }) => throw UnimplementedError(); + + @override + Future delete( + String path, { + ParseNetworkOptions? options, + }) => throw UnimplementedError(); + + @override + Future getBytes( + String path, { + ParseNetworkOptions? options, + ProgressCallback? onReceiveProgress, + dynamic cancelToken, + }) => throw UnimplementedError(); + + // Exposes the protected helper so tests can call it without subclass tricks. + Future?> exposedBuildHeaders( + ParseNetworkOptions? options, + ) => buildHeaders(options); +} + +void main() { + setUpAll(() async { + await initializeParse(); + }); + + group('ParseClient.buildHeaders — X-Parse-Installation-Id handling', () { + late _StubParseClient client; + + setUp(() { + client = _StubParseClient(); + }); + + test( + 'attaches X-Parse-Installation-Id by default. Matches iOS ' + 'PFURLSessionCommandRunner behaviour — every request carries the ' + 'install ID so parse-server can bind created _Session rows to the ' + 'right installation and so destroyDuplicatedSessions can clean up ' + 'prior sessions on the same install during login', + () async { + final Map? headers = await client.exposedBuildHeaders( + null, + ); + + expect(headers, isNotNull); + expect(headers![keyHeaderInstallationId], isNotEmpty); + }, + ); + + test( + 'omits X-Parse-Installation-Id when caller passes ' + 'sendInstallationId: false. The opt-out is forwarded by methods such ' + 'as ParseUser.signUp(doNotSendInstallationID: true) for callers that ' + 'cannot allow-list the header on their parse-server', + () async { + final Map? headers = await client.exposedBuildHeaders( + ParseNetworkOptions(sendInstallationId: false), + ); + + expect( + headers?[keyHeaderInstallationId], + isNull, + reason: + 'sendInstallationId=false must suppress the header even when ' + 'an install ID is available', + ); + }, + ); + + test( + 'preserves a caller-supplied X-Parse-Installation-Id rather than ' + 'overwriting it. Lets advanced callers (tests, multi-tenant proxies) ' + 'inject a specific install ID without the client clobbering it', + () async { + final Map? headers = await client.exposedBuildHeaders( + ParseNetworkOptions( + headers: {keyHeaderInstallationId: 'caller-id'}, + ), + ); + + expect(headers![keyHeaderInstallationId], equals('caller-id')); + }, + ); + + test( + 'merges caller-supplied headers with the install ID. Custom headers ' + 'and the auto-attached install ID must coexist — neither side ' + 'overrides the other', + () async { + final Map? headers = await client.exposedBuildHeaders( + ParseNetworkOptions( + headers: {'X-Custom': 'value'}, + ), + ); + + expect(headers!['X-Custom'], equals('value')); + expect(headers[keyHeaderInstallationId], isNotEmpty); + }, + ); + }); +} diff --git a/packages/dart/test/src/network/parse_dio_client_test.dart b/packages/dart/test/src/network/parse_dio_client_test.dart index a0723808c..7d4f21079 100644 --- a/packages/dart/test/src/network/parse_dio_client_test.dart +++ b/packages/dart/test/src/network/parse_dio_client_test.dart @@ -4,6 +4,27 @@ import 'package:test/test.dart'; import '../../test_utils.dart'; +/// Records the headers of every request made through it without performing +/// the actual HTTP call. Returns an empty 200 OK so callers can `await`. +class _HeaderCapturingAdapter implements HttpClientAdapter { + final List> requests = []; + + @override + Future fetch( + RequestOptions options, + Stream>? requestStream, + Future? cancelFuture, + ) async { + requests.add(Map.from(options.headers)); + return ResponseBody.fromString('{}', 200, headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }); + } + + @override + void close({bool force = false}) {} +} + void main() { setUpAll(() async { await initializeParse(); @@ -57,4 +78,33 @@ void main() { expect(parseDioClient.additionalHeaders, isNull); }); }); + + group('ParseDioClient request pipeline integration', () { + late ParseDioClient parseDioClient; + late _HeaderCapturingAdapter adapter; + + setUp(() async { + parseDioClient = ParseDioClient(); + adapter = _HeaderCapturingAdapter(); + parseDioClient.client.httpClientAdapter = adapter; + }); + + test( + 'headers returned by buildHeaders reach the outgoing request. This is ' + 'the wiring check between the inherited helper (covered in detail by ' + 'parse_client_test.dart) and dio\'s request pipeline — without it, a ' + 'refactor that bypassed buildHeaders would silently drop install IDs ' + 'on every request', + () async { + await parseDioClient.put('$serverUrl/classes/_User/abc', data: '{}'); + + expect(adapter.requests, hasLength(1)); + expect( + adapter.requests.first[keyHeaderInstallationId], + isNotEmpty, + reason: 'install ID added by buildHeaders must reach the wire', + ); + }, + ); + }); } diff --git a/packages/dart/test/src/network/parse_http_client_test.dart b/packages/dart/test/src/network/parse_http_client_test.dart index 9f0a79751..8895d5c72 100644 --- a/packages/dart/test/src/network/parse_http_client_test.dart +++ b/packages/dart/test/src/network/parse_http_client_test.dart @@ -9,20 +9,20 @@ void main() { await initializeParse(); }); - group('ParseDioClient Tests', () { + group('ParseHTTPClient Tests', () { late ParseHTTPClient parseHTTPClient; setUp(() async { parseHTTPClient = ParseHTTPClient(); }); - test('should return an instance of Dio from dioClient', () { + test('should return an instance of http.BaseClient from client getter', () { // arrange - final dioClient = parseHTTPClient.client; + final httpClient = parseHTTPClient.client; // assert - expect(dioClient, isNotNull); - expect(dioClient, isA()); + expect(httpClient, isNotNull); + expect(httpClient, isA()); }); }); } From 6a53cfa2668026f105b5d540f133b30395b7a278 Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Sat, 16 May 2026 18:08:49 -0500 Subject: [PATCH 6/9] style: Format test files with dart format CI runs `dart format --set-exit-if-changed` and rejects unformatted files. Brings the three test files added in the prior two commits (commits 83244367 and 5b2cd09e) into conformance. --- .../test/src/network/parse_client_test.dart | 85 +++---- .../src/network/parse_dio_client_test.dart | 41 ++-- .../parse_user_session_token_test.dart | 225 +++++++++--------- 3 files changed, 166 insertions(+), 185 deletions(-) diff --git a/packages/dart/test/src/network/parse_client_test.dart b/packages/dart/test/src/network/parse_client_test.dart index 83a0c1ce2..5ed1e11d8 100644 --- a/packages/dart/test/src/network/parse_client_test.dart +++ b/packages/dart/test/src/network/parse_client_test.dart @@ -69,41 +69,35 @@ void main() { client = _StubParseClient(); }); - test( - 'attaches X-Parse-Installation-Id by default. Matches iOS ' - 'PFURLSessionCommandRunner behaviour — every request carries the ' - 'install ID so parse-server can bind created _Session rows to the ' - 'right installation and so destroyDuplicatedSessions can clean up ' - 'prior sessions on the same install during login', - () async { - final Map? headers = await client.exposedBuildHeaders( - null, - ); - - expect(headers, isNotNull); - expect(headers![keyHeaderInstallationId], isNotEmpty); - }, - ); - - test( - 'omits X-Parse-Installation-Id when caller passes ' - 'sendInstallationId: false. The opt-out is forwarded by methods such ' - 'as ParseUser.signUp(doNotSendInstallationID: true) for callers that ' - 'cannot allow-list the header on their parse-server', - () async { - final Map? headers = await client.exposedBuildHeaders( - ParseNetworkOptions(sendInstallationId: false), - ); + test('attaches X-Parse-Installation-Id by default. Matches iOS ' + 'PFURLSessionCommandRunner behaviour — every request carries the ' + 'install ID so parse-server can bind created _Session rows to the ' + 'right installation and so destroyDuplicatedSessions can clean up ' + 'prior sessions on the same install during login', () async { + final Map? headers = await client.exposedBuildHeaders( + null, + ); + + expect(headers, isNotNull); + expect(headers![keyHeaderInstallationId], isNotEmpty); + }); - expect( - headers?[keyHeaderInstallationId], - isNull, - reason: - 'sendInstallationId=false must suppress the header even when ' - 'an install ID is available', - ); - }, - ); + test('omits X-Parse-Installation-Id when caller passes ' + 'sendInstallationId: false. The opt-out is forwarded by methods such ' + 'as ParseUser.signUp(doNotSendInstallationID: true) for callers that ' + 'cannot allow-list the header on their parse-server', () async { + final Map? headers = await client.exposedBuildHeaders( + ParseNetworkOptions(sendInstallationId: false), + ); + + expect( + headers?[keyHeaderInstallationId], + isNull, + reason: + 'sendInstallationId=false must suppress the header even when ' + 'an install ID is available', + ); + }); test( 'preserves a caller-supplied X-Parse-Installation-Id rather than ' @@ -120,20 +114,15 @@ void main() { }, ); - test( - 'merges caller-supplied headers with the install ID. Custom headers ' - 'and the auto-attached install ID must coexist — neither side ' - 'overrides the other', - () async { - final Map? headers = await client.exposedBuildHeaders( - ParseNetworkOptions( - headers: {'X-Custom': 'value'}, - ), - ); + test('merges caller-supplied headers with the install ID. Custom headers ' + 'and the auto-attached install ID must coexist — neither side ' + 'overrides the other', () async { + final Map? headers = await client.exposedBuildHeaders( + ParseNetworkOptions(headers: {'X-Custom': 'value'}), + ); - expect(headers!['X-Custom'], equals('value')); - expect(headers[keyHeaderInstallationId], isNotEmpty); - }, - ); + expect(headers!['X-Custom'], equals('value')); + expect(headers[keyHeaderInstallationId], isNotEmpty); + }); }); } diff --git a/packages/dart/test/src/network/parse_dio_client_test.dart b/packages/dart/test/src/network/parse_dio_client_test.dart index 7d4f21079..1eb2efd1c 100644 --- a/packages/dart/test/src/network/parse_dio_client_test.dart +++ b/packages/dart/test/src/network/parse_dio_client_test.dart @@ -16,9 +16,13 @@ class _HeaderCapturingAdapter implements HttpClientAdapter { Future? cancelFuture, ) async { requests.add(Map.from(options.headers)); - return ResponseBody.fromString('{}', 200, headers: >{ - Headers.contentTypeHeader: [Headers.jsonContentType], - }); + return ResponseBody.fromString( + '{}', + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); } @override @@ -89,22 +93,19 @@ void main() { parseDioClient.client.httpClientAdapter = adapter; }); - test( - 'headers returned by buildHeaders reach the outgoing request. This is ' - 'the wiring check between the inherited helper (covered in detail by ' - 'parse_client_test.dart) and dio\'s request pipeline — without it, a ' - 'refactor that bypassed buildHeaders would silently drop install IDs ' - 'on every request', - () async { - await parseDioClient.put('$serverUrl/classes/_User/abc', data: '{}'); - - expect(adapter.requests, hasLength(1)); - expect( - adapter.requests.first[keyHeaderInstallationId], - isNotEmpty, - reason: 'install ID added by buildHeaders must reach the wire', - ); - }, - ); + test('headers returned by buildHeaders reach the outgoing request. This is ' + 'the wiring check between the inherited helper (covered in detail by ' + 'parse_client_test.dart) and dio\'s request pipeline — without it, a ' + 'refactor that bypassed buildHeaders would silently drop install IDs ' + 'on every request', () async { + await parseDioClient.put('$serverUrl/classes/_User/abc', data: '{}'); + + expect(adapter.requests, hasLength(1)); + expect( + adapter.requests.first[keyHeaderInstallationId], + isNotEmpty, + reason: 'install ID added by buildHeaders must reach the wire', + ); + }); }); } 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..7cd241f14 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 @@ -24,122 +24,113 @@ void main() { 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')); - }, - ); + 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 98e96eb2820f60231f878d75a858e3187455f11d Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Sat, 16 May 2026 18:14:04 -0500 Subject: [PATCH 7/9] refactor: Use _keyAuthAnonymous constant in loginAnonymous payload Addresses upstream PR #1136 review feedback. The loginAnonymous request body literally wrote 'anonymous' while the rest of the file (stripAnonymity branches at lines 537/541/543) references the `_keyAuthAnonymous` constant defined at line 46. One-line change brings the body into alignment. --- packages/dart/lib/src/objects/parse_user.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dart/lib/src/objects/parse_user.dart b/packages/dart/lib/src/objects/parse_user.dart index eac47f192..7eac44dc5 100644 --- a/packages/dart/lib/src/objects/parse_user.dart +++ b/packages/dart/lib/src/objects/parse_user.dart @@ -303,7 +303,7 @@ class ParseUser extends ParseObject implements ParseCloneable { ), data: jsonEncode({ 'authData': { - 'anonymous': {'id': uuid.v4()}, + _keyAuthAnonymous: {'id': uuid.v4()}, }, }), ); From 20ca6c53c5d37cfde17d4411fa99485427ee69c7 Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Sat, 16 May 2026 18:34:22 -0500 Subject: [PATCH 8/9] docs: Soften revokeSessionOnPasswordReset version claim in dartdoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We don't know when parse-server first defaulted revokeSessionOnPasswordReset to true. "default since 9.x" overclaims historical knowledge. Reword to "current default in 9.x" — accurate, no implicit version-history assertion. --- packages/dart/lib/src/objects/parse_user.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dart/lib/src/objects/parse_user.dart b/packages/dart/lib/src/objects/parse_user.dart index 7eac44dc5..4b4b7f226 100644 --- a/packages/dart/lib/src/objects/parse_user.dart +++ b/packages/dart/lib/src/objects/parse_user.dart @@ -516,7 +516,7 @@ class ParseUser extends ParseObject implements ParseCloneable { /// Adopt a new sessionToken from a save/update response. Parse Server /// mints a fresh session when `password` is set on an existing _User - /// (revokeSessionOnPasswordReset, default true since 9.x); the prior + /// (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. From 9cd24a58afee492c9fd776bc3022141767a56875 Mon Sep 17 00:00:00 2001 From: Chad Pavliska Date: Sat, 16 May 2026 18:52:21 -0500 Subject: [PATCH 9/9] build: Regenerate mocks for new ParseClient.buildHeaders method Adding the protected buildHeaders helper to ParseClient (commit 5b2cd09e) requires regenerating MockParseClient via build_runner. CI's pub publish --dry-run check rejects dirty generated files; this commit lands the up-to-date mocks. --- .../parse_client_retry_integration_test.mocks.dart | 10 ++++++++++ .../dart/test/src/network/parse_query_test.mocks.dart | 10 ++++++++++ .../objects/parse_object/parse_object_test.mocks.dart | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/packages/dart/test/src/network/parse_client_retry_integration_test.mocks.dart b/packages/dart/test/src/network/parse_client_retry_integration_test.mocks.dart index 9bc333c4e..ff801802b 100644 --- a/packages/dart/test/src/network/parse_client_retry_integration_test.mocks.dart +++ b/packages/dart/test/src/network/parse_client_retry_integration_test.mocks.dart @@ -209,4 +209,14 @@ class MockParseClient extends _i1.Mock implements _i2.ParseClient { ), ) as _i3.Future<_i2.ParseNetworkByteResponse>); + + @override + _i3.Future?> buildHeaders( + _i2.ParseNetworkOptions? options, + ) => + (super.noSuchMethod( + Invocation.method(#buildHeaders, [options]), + returnValue: _i3.Future?>.value(), + ) + as _i3.Future?>); } diff --git a/packages/dart/test/src/network/parse_query_test.mocks.dart b/packages/dart/test/src/network/parse_query_test.mocks.dart index 4cdbf3808..862284629 100644 --- a/packages/dart/test/src/network/parse_query_test.mocks.dart +++ b/packages/dart/test/src/network/parse_query_test.mocks.dart @@ -209,4 +209,14 @@ class MockParseClient extends _i1.Mock implements _i2.ParseClient { ), ) as _i3.Future<_i2.ParseNetworkByteResponse>); + + @override + _i3.Future?> buildHeaders( + _i2.ParseNetworkOptions? options, + ) => + (super.noSuchMethod( + Invocation.method(#buildHeaders, [options]), + returnValue: _i3.Future?>.value(), + ) + as _i3.Future?>); } diff --git a/packages/dart/test/src/objects/parse_object/parse_object_test.mocks.dart b/packages/dart/test/src/objects/parse_object/parse_object_test.mocks.dart index 39a066aef..21904274f 100644 --- a/packages/dart/test/src/objects/parse_object/parse_object_test.mocks.dart +++ b/packages/dart/test/src/objects/parse_object/parse_object_test.mocks.dart @@ -209,4 +209,14 @@ class MockParseClient extends _i1.Mock implements _i2.ParseClient { ), ) as _i3.Future<_i2.ParseNetworkByteResponse>); + + @override + _i3.Future?> buildHeaders( + _i2.ParseNetworkOptions? options, + ) => + (super.noSuchMethod( + Invocation.method(#buildHeaders, [options]), + returnValue: _i3.Future?>.value(), + ) + as _i3.Future?>); }