Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/dart/lib/src/network/parse_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ abstract class ParseClient {
}
String? installationId;
try {
installationId =
(await ParseInstallation.currentInstallation()).installationId;
// Cached after first call — the install ID is immutable per device, so
// we avoid hitting the local store + JSON-decoding the full installation
// on every HTTP request.
installationId = await ParseInstallation.currentInstallationId();
} catch (_) {
return options?.headers;
}
Expand Down
114 changes: 111 additions & 3 deletions packages/dart/lib/src/objects/parse_installation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ class ParseInstallation extends ParseObject {
];
static String? _currentInstallationId;

/// Single source of truth for "is this installationId usable as a header
/// value and a request body field". Empty strings and whitespace-only
/// strings are rejected; a stale store containing either should fall
/// through to UUID regeneration rather than be reused.
static bool _isUsableInstallationId(Object? value) {
return value is String && value.trim().isNotEmpty;
}

//Getters/setters
Map<String, dynamic> get acl => super.get<Map<String, dynamic>>(
keyVarAcl,
Expand Down Expand Up @@ -53,7 +61,15 @@ class ParseInstallation extends ParseObject {
String? get parseVersion => super.get<String>(keyParseVersion);

static Future<bool> isCurrent(ParseInstallation installation) async {
_currentInstallationId ??= (await _getFromLocalStore())?.installationId;
// Treat an empty-string cache as missing. A previously poisoned local
// store could populate the cache with "", and `??=` would happily leave
// the empty string in place — poisoning every subsequent header and
// POST body.
if (!_isUsableInstallationId(_currentInstallationId)) {
final String? stored = (await _getFromLocalStore())?.installationId;
_currentInstallationId =
_isUsableInstallationId(stored) ? stored : null;
}
return _currentInstallationId != null &&
installation.installationId == _currentInstallationId;
}
Expand All @@ -63,6 +79,61 @@ class ParseInstallation extends ParseObject {
return (await _getFromLocalStore()) ?? (await _createInstallation());
}

/// Returns the current installation's UUID. Hot path for
/// `ParseClient.buildHeaders`, which runs on every HTTP request.
///
/// The first call reads `keyInstallationId` directly from the JSON stored
/// at [keyParseStoreInstallation] — without going through `fromJson` and
/// constructing a full `ParseInstallation` with its dirty-tracking maps.
/// Subsequent calls return the value from the static cache in
/// [_currentInstallationId]. The install ID is immutable for the lifetime
/// of the app on a given device, so the cache never needs invalidation.
static Future<String?> currentInstallationId() async {
if (_isUsableInstallationId(_currentInstallationId)) {
return _currentInstallationId;
}
final String? stored = await _readInstallationIdFromStore();
if (stored != null) {
_currentInstallationId = stored;
return _currentInstallationId;
}
// Bootstrap. `_createInstallation` sets `_currentInstallationId` via `??=`
// *before* it attempts persistence; if persistence throws we clear the
// cache so the next call retries the write. Matches the pre-cache
// behaviour of `currentInstallation()`, which would re-enter
// `_createInstallation` on every call until storage succeeded.
try {
await _createInstallation();
} catch (_) {
_currentInstallationId = null;
rethrow;
}
return _currentInstallationId;
}

/// Clears the cached installation UUID so the next call to
/// [currentInstallationId] re-reads from the local store. Intended for tests
/// that wipe storage between cases, and for apps that re-initialize Parse or
/// clear the core store mid-session.
@visibleForTesting
static void debugResetInstallationIdCache() {
_currentInstallationId = null;
}

/// Reads just the installation UUID out of the locally-stored JSON map,
/// avoiding a full `ParseInstallation()..fromJson(...)` round-trip when the
/// caller only needs the ID (see [currentInstallationId]).
static Future<String?> _readInstallationIdFromStore() async {
final String? installationJson = await ParseCoreData()
.getStore()
.getString(keyParseStoreInstallation);
if (installationJson == null) return null;
final dynamic decoded = json.decode(installationJson);
if (decoded is! Map<String, dynamic>) return null;
final dynamic id = decoded[keyInstallationId];
return _isUsableInstallationId(id) ? id as String : null;
}

/// Updates the installation with current device data
Future<void> _updateInstallation() async {
//Device type
Expand Down Expand Up @@ -91,6 +162,22 @@ class ParseInstallation extends ParseObject {
set<String?>(keyAppVersion, ParseCoreData().appVersion);
set<String?>(keyAppIdentifier, ParseCoreData().appPackageName);
set<String>(keyParseVersion, keySdkVersion);

// Re-stage installationId into `_unsavedChanges` so `_create()`'s
// `toJson(forApiRQ: true)` body actually carries it. On a subsequent
// launch (process died before the first server POST) `_getFromLocalStore`
// uses `fromJson(addInUnSave: false)`, which only populates `_objectData`.
// Without this gate, the next POST would create a server row with no
// `installationId`, breaking any later server-side query that matches by
// the UUID the device sends in `X-Parse-Installation-Id`. Gated on
// `objectId == null` so the SDK never tries to mutate a read-only field
// on an already-server-persisted row.
if (objectId == null) {
final String? currentId = installationId;
if (_isUsableInstallationId(currentId)) {
set<String>(keyInstallationId, currentId!);
}
}
}

String _getNameLocalTimeZone() {
Expand Down Expand Up @@ -147,7 +234,14 @@ class ParseInstallation extends ParseObject {
return parseResponse;
}

/// Gets the locally stored installation
/// Gets the locally stored installation.
///
/// Returns null if the stored JSON is missing or unusable (no
/// installationId, or installationId is the empty string / whitespace /
/// wrong type). Callers fall through to [_createInstallation], which
/// generates a fresh UUID rather than reusing a poisoned value.
/// Centralizing the validity check here keeps corrupted local state from
/// leaking into server requests.
static Future<ParseInstallation?> _getFromLocalStore() async {
final CoreStore coreStore = ParseCoreData().getStore();

Expand All @@ -161,6 +255,15 @@ class ParseInstallation extends ParseObject {
);

if (installationMap != null) {
if (!_isUsableInstallationId(installationMap[keyInstallationId])) {
if (ParseCoreData().debug) {
print(
'ParseInstallation: discarding stored installation with '
'missing/empty installationId; a new UUID will be minted.',
);
}
return null;
}
return ParseInstallation()..fromJson(installationMap);
}
}
Expand All @@ -172,7 +275,12 @@ class ParseInstallation extends ParseObject {
/// Assumes that this is called because there is no previous installation
/// so it creates and sets the static current installation UUID
static Future<ParseInstallation> _createInstallation() async {
_currentInstallationId ??= const Uuid().v4();
// Explicit null-or-empty check. `??=` would leave a cached empty string
// in place — that was the bug that wrote `installationId: ""` to the
// server and produced rows the device's later UUID could never match.
if (!_isUsableInstallationId(_currentInstallationId)) {
_currentInstallationId = const Uuid().v4();
}

final ParseInstallation installation = ParseInstallation();
installation._installationId = _currentInstallationId;
Expand Down
135 changes: 123 additions & 12 deletions packages/dart/test/parse_installation_test.dart
Original file line number Diff line number Diff line change
@@ -1,30 +1,141 @@
import 'dart:convert';

import 'package:parse_server_sdk/parse_server_sdk.dart';
import 'package:test/test.dart';

void main() {
test('should return true for exist TimeZone.', () async {
// arrange
await Parse().initialize(
Future<void> _initParse() => Parse().initialize(
'appId',
'https://example.com',
debug: true,
// to prevent automatic detection
fileDirectory: 'someDirectory',
// to prevent automatic detection
appName: 'appName',
// to prevent automatic detection
appPackageName: 'somePackageName',
// to prevent automatic detection
appVersion: 'someAppVersion',
);

// act
void main() {
setUpAll(() async {
await _initParse();
});

// Each test starts from a clean local store and a cleared install-id cache
// so a test that pre-seeds the store isn't contaminated by a previous one.
setUp(() async {
await ParseCoreData().getStore().remove(keyParseStoreInstallation);
ParseInstallation.debugResetInstallationIdCache();
});

test('should return true for exist TimeZone.', () async {
final ParseInstallation installation =
await ParseInstallation.currentInstallation();
expect(installation.containsKey(keyTimeZone), isTrue);
});

// Regression: when an installation is loaded from the local store on a
// subsequent launch (process died before the first server save), `fromJson`
// populates `_objectData` but not `_unsavedChanges`. `_create()` POSTs
// `toJson(forApiRQ: true)`, which reads from `_unsavedChanges` — without
// the gate in `_updateInstallation()`, the POST body would carry no
// `installationId` and the server would create a row whose
// `installationId` column is empty.
test('_updateInstallation re-stages installationId so create() POSTs it',
() async {
const String storedId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
final Map<String, dynamic> storedJson = <String, dynamic>{
keyVarClassName: keyClassInstallation,
keyInstallationId: storedId,
keyDeviceType: 'android',
keyAppName: 'appName',
};
await ParseCoreData()
.getStore()
.setString(keyParseStoreInstallation, jsonEncode(storedJson));

final installation = await ParseInstallation.currentInstallation();
expect(installation.installationId, equals(storedId));
expect(installation.objectId, isNull);

// Pre-condition: load-from-store uses `fromJson(addInUnSave: false)`, so
// the install ID lives in `_objectData` only. `toJson(forApiRQ: true)`
// reads `_unsavedChanges` — proving the regression exists if the gate
// were removed.
final Map<String, dynamic> bodyBeforeUpdate =
installation.toJson(forApiRQ: true);
expect(
bodyBeforeUpdate.containsKey(keyInstallationId),
isFalse,
reason: 'Sanity check: installationId is in _objectData only after '
'a fresh load from the local store.',
);

// Drive the same path the SDK takes before POST /installations. The
// network call will fail (pointed at https://example.com), but by then
// `_updateInstallation()` has run and re-staged installationId.
final ParseResponse response = await installation.create();
expect(response.success, isFalse);
final Map<String, dynamic> apiBody =
installation.toJson(forApiRQ: true);
expect(
apiBody[keyInstallationId],
equals(storedId),
reason:
'installationId must be in _unsavedChanges so the create() POST '
'body carries it. Otherwise the server creates a row whose '
'`installationId` column is empty.',
);
});

// Regression: a corrupted local store with `installationId: ""` should not
// be reused. `_getFromLocalStore` must return null so `_createInstallation`
// generates a fresh UUID instead.
test('empty installationId in local store is rejected', () async {
final Map<String, dynamic> poisonedJson = <String, dynamic>{
keyVarClassName: keyClassInstallation,
keyInstallationId: '',
keyDeviceType: 'android',
};
await ParseCoreData()
.getStore()
.setString(keyParseStoreInstallation, jsonEncode(poisonedJson));

final installation = await ParseInstallation.currentInstallation();
expect(installation.installationId, isNotNull);
expect(installation.installationId, isNotEmpty);
expect(installation.installationId!.trim(), isNotEmpty);
});

// Whitespace-only IDs would survive an `isNotEmpty` check; make sure the
// shared validator catches them too.
test('whitespace-only installationId in local store is rejected', () async {
final Map<String, dynamic> poisonedJson = <String, dynamic>{
keyVarClassName: keyClassInstallation,
keyInstallationId: ' ',
keyDeviceType: 'android',
};
await ParseCoreData()
.getStore()
.setString(keyParseStoreInstallation, jsonEncode(poisonedJson));

final installation = await ParseInstallation.currentInstallation();
expect(installation.installationId, isNotNull);
expect(installation.installationId!.trim(), isNotEmpty);
expect(installation.installationId, isNot(equals(' ')));
});

dynamic actualHasTimeZoneResult = installation.containsKey(keyTimeZone);
// Non-string installationId (e.g. corrupted migration that wrote a Map)
// should be discarded rather than crash the type cast.
test('non-string installationId in local store is rejected', () async {
final Map<String, dynamic> poisonedJson = <String, dynamic>{
keyVarClassName: keyClassInstallation,
keyInstallationId: <String, dynamic>{'unexpected': 'shape'},
keyDeviceType: 'android',
};
await ParseCoreData()
.getStore()
.setString(keyParseStoreInstallation, jsonEncode(poisonedJson));

// assert
expect(actualHasTimeZoneResult, true);
final installation = await ParseInstallation.currentInstallation();
expect(installation.installationId, isNotNull);
expect(installation.installationId, isNotEmpty);
});
}