Skip to content
Closed
49 changes: 47 additions & 2 deletions packages/dart/lib/src/objects/parse_user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -70,7 +71,10 @@ class ParseUser extends ParseObject implements ParseCloneable {

String? get username => super.get<String>(keyVarUsername);

set username(String? username) => set<String?>(keyVarUsername, username);
set username(String? username) {
_stripAnonymity();
set<String?>(keyVarUsername, username);
}

String? get emailAddress => super.get<String>(keyVarEmail);

Expand Down Expand Up @@ -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<ParseResponse> loginAnonymous({
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -518,6 +530,39 @@ class ParseUser extends ParseObject implements ParseCloneable {
await saveInStorage(keyParseStoreUser);
}

void _stripAnonymity() {
final Map<String, dynamic>? authData =
_objectData[keyVarAuthData] as Map<String, dynamic>?;
if (authData == null || !authData.containsKey(_keyAuthAnonymous)) {
return;
}
if (objectId == null) {
authData.remove(_keyAuthAnonymous);
} else {
authData[_keyAuthAnonymous] = null;
}
_unsavedChanges[keyVarAuthData] = authData;
}

void _cleanUpAuthData() {
final Map<String, dynamic>? authData =
_objectData[keyVarAuthData] as Map<String, dynamic>?;
if (authData != null) {
authData.removeWhere((_, dynamic value) => value == null);
if (authData.isEmpty) {
_objectData.remove(keyVarAuthData);
}
}
final Map<String, dynamic>? dirty =
_unsavedChanges[keyVarAuthData] as Map<String, dynamic>?;
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<ParseResponse?> destroy() async {
if (objectId != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, dynamic>? extraAuthData,
}) {
final ParseUser user = ParseUser(null, null, null, client: client);
final Map<String, dynamic> authData = <String, dynamic>{
'anonymous': <String, dynamic>{'id': anonymousId},
};
if (extraAuthData != null) {
authData.addAll(extraAuthData);
}
user.fromJson(<String, dynamic>{
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(<String, dynamic>{
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<String, dynamic>? 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(<String, dynamic>{
keyVarUpdatedAt: '2026-04-28T12:00:01.000Z',
}),
);
});

user.username = 'alice@example.com';
user.password = 'hunter2';

await user.save();

expect(capturedBody, isNotNull);
final Map<String, dynamic> decoded =
jsonDecode(capturedBody!) as Map<String, dynamic>;
expect(decoded[keyVarAuthData], isA<Map>());
final Map<String, dynamic> sentAuthData =
(decoded[keyVarAuthData] as Map).cast<String, dynamic>();
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: <String, dynamic>{
'facebook': <String, dynamic>{
'id': 'fb-12345',
'access_token': 'tok',
},
},
);

when(
client.put(
putPath,
options: anyNamed('options'),
data: anyNamed('data'),
),
).thenAnswer(
(_) async => ParseNetworkResponse(
statusCode: 200,
data: jsonEncode(<String, dynamic>{
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(<String, dynamic>{
keyVarAuthData: <String, dynamic>{
'anonymous': <String, dynamic>{'id': anonymousId},
},
});

user.username = 'alice@example.com';

final Map<String, dynamic>? 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(<String, dynamic>{
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(<String, dynamic>{
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<String, dynamic> decoded =
jsonDecode(capturedBody!) as Map<String, dynamic>;
expect(
decoded.containsKey(keyVarAuthData),
isFalse,
reason:
'a regular username update on a non-anonymous user should not '
'synthesize authData in the request body',
);
},
);
});
}
Loading