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 c89a5e87d..4b4b7f226 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); @@ -216,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, ); @@ -255,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, ), ); @@ -282,7 +278,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({ @@ -292,20 +294,16 @@ 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': { - 'anonymous': {'id': uuid.v4()}, + _keyAuthAnonymous: {'id': uuid.v4()}, }, }), ); @@ -356,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), ); @@ -493,8 +487,11 @@ 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(); } return response; @@ -506,18 +503,71 @@ 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(); } 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); } + 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; + } + if (authData.isEmpty) { + _unsavedChanges.remove(keyVarAuthData); + } else { + _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) { @@ -630,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_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_client_test.dart b/packages/dart/test/src/network/parse_client_test.dart new file mode 100644 index 000000000..5ed1e11d8 --- /dev/null +++ b/packages/dart/test/src/network/parse_client_test.dart @@ -0,0 +1,128 @@ +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..1eb2efd1c 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,31 @@ 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 +82,30 @@ 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()); }); }); } 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?>); } 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..3f604f9f7 --- /dev/null +++ b/packages/dart/test/src/objects/parse_user/parse_user_anonymous_link_test.dart @@ -0,0 +1,301 @@ +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. 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', + ); + }); + + 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 ' + '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', + ); + }, + ); + }); +} 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..7cd241f14 --- /dev/null +++ b/packages/dart/test/src/objects/parse_user/parse_user_session_token_test.dart @@ -0,0 +1,136 @@ +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')); + }); + }); +}