Files
Client/test/push/push_dual_registration_test.dart
T

218 lines
6.6 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_registration.dart';
import 'package:marianum_mobile/push/push_registration_store.dart';
import 'package:marianum_mobile/push/push_registration_type.dart';
import 'package:marianum_mobile/push/push_secure_storage.dart';
class _MemoryStorage implements FlutterSecureStorageLike {
final Map<String, String> values = {};
@override
Future<String?> read({required String key}) async => values[key];
@override
Future<void> write({required String key, required String? value}) async {
if (value == null) {
values.remove(key);
} else {
values[key] = value;
}
}
@override
Future<void> delete({required String key}) async => values.remove(key);
}
void main() {
group('talk user agents', () {
// Quoted from nextcloud/server lib/public/IRequest.php.
final uaTalkAndroid = RegExp(
r'^Mozilla/5\.0 \(Android\) Nextcloud-Talk v([^ ]*).*$',
);
final uaTalkIos = RegExp(
r'^Mozilla/5\.0 \(iOS\) Nextcloud-Talk v([^ ]*).*$',
);
test('android UA matches USER_AGENT_TALK_ANDROID', () {
expect(
uaTalkAndroid.hasMatch(PushRegistration.talkUserAgentAndroid),
isTrue,
);
expect(
uaTalkIos.hasMatch(PushRegistration.talkUserAgentAndroid),
isFalse,
);
});
test('ios UA matches USER_AGENT_TALK_IOS', () {
expect(uaTalkIos.hasMatch(PushRegistration.talkUserAgentIos), isTrue);
expect(
uaTalkAndroid.hasMatch(PushRegistration.talkUserAgentIos),
isFalse,
);
});
});
group('registrationTypesFor', () {
test('password accounts maintain both registrations', () {
expect(
PushRegistration.registrationTypesFor(
usesLoginFlow: false,
hasTalkAppPassword: false,
),
PushRegistrationType.values,
);
expect(
PushRegistration.registrationTypesFor(
usesLoginFlow: false,
hasTalkAppPassword: true,
),
PushRegistrationType.values,
);
});
test('flow account without talk app password is talk-only', () {
expect(
PushRegistration.registrationTypesFor(
usesLoginFlow: true,
hasTalkAppPassword: false,
),
[PushRegistrationType.talk],
);
});
test('flow account with second (talk) app password maintains both', () {
expect(
PushRegistration.registrationTypesFor(
usesLoginFlow: true,
hasTalkAppPassword: true,
),
PushRegistrationType.values,
);
});
});
group('pushTokenVariant', () {
test('general uses the raw token, talk appends the suffix', () {
expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok');
expect(pushTokenVariant('tok', PushRegistrationType.talk), 'tok#talk');
});
test('variants always differ (NC would delete same-hash siblings)', () {
const token = 'any-token';
expect(
pushTokenVariant(token, PushRegistrationType.general),
isNot(pushTokenVariant(token, PushRegistrationType.talk)),
);
});
});
group('PushRegistrationStore', () {
test('pre-dual entries are read as the general registration', () async {
final storage = _MemoryStorage();
// State written by a pre-dual app version (no type suffixes).
storage.values.addAll({
'push_device_identifier': 'legacy-device',
'push_server_public_key_pem': 'legacy-key',
'push_registered_fcm_token': 'legacy-token',
'push_registered_proxy_server': 'https://old/push-proxy/',
'push_registered_nc_base_url': 'https://cloud',
'push_last_registration_at': '2026-07-01T10:00:00.000',
'push_last_registration_error': '',
});
final store = PushRegistrationStore(storage);
expect(
await store.deviceIdentifier(PushRegistrationType.general),
'legacy-device',
);
expect(await store.isRegistered(PushRegistrationType.general), isTrue);
expect(
await store.registeredProxyServer(PushRegistrationType.general),
'https://old/push-proxy/',
);
expect(
await store.lastRegistrationAt(PushRegistrationType.general),
DateTime(2026, 7, 1, 10),
);
// The talk registration is genuinely absent — the self-heal adds it.
expect(await store.deviceIdentifier(PushRegistrationType.talk), isNull);
expect(await store.isRegistered(PushRegistrationType.talk), isFalse);
});
test('per-type values stay independent', () async {
final storage = _MemoryStorage();
final store = PushRegistrationStore(storage);
await store.save(
type: PushRegistrationType.general,
deviceIdentifier: 'dev-general',
serverPublicKeyPem: 'server-key',
fcmToken: 'token',
proxyServer: 'https://a/push-proxy/',
ncBaseUrl: 'https://cloud',
);
await store.save(
type: PushRegistrationType.talk,
deviceIdentifier: 'dev-talk',
serverPublicKeyPem: 'server-key',
fcmToken: 'token',
proxyServer: 'https://a/push-proxy/',
ncBaseUrl: 'https://cloud',
);
await store.saveLastRegistrationAttempt(
type: PushRegistrationType.talk,
at: DateTime(2026, 7, 5, 12),
error: 'HTTP 404',
);
expect(
await store.deviceIdentifier(PushRegistrationType.general),
'dev-general',
);
expect(
await store.deviceIdentifier(PushRegistrationType.talk),
'dev-talk',
);
// Shared server key: last write wins, both read the same value.
expect(await store.serverPublicKeyPem(), 'server-key');
expect(
await store.lastRegistrationError(PushRegistrationType.general),
isNull,
);
expect(
await store.lastRegistrationError(PushRegistrationType.talk),
'HTTP 404',
);
});
test('clear removes both registrations and shared keys', () async {
final storage = _MemoryStorage();
final store = PushRegistrationStore(storage);
for (final type in PushRegistrationType.values) {
await store.save(
type: type,
deviceIdentifier: 'dev',
serverPublicKeyPem: 'key',
fcmToken: 'token',
proxyServer: 'https://a/',
ncBaseUrl: 'https://cloud',
);
await store.saveLastRegistrationAttempt(
type: type,
at: DateTime(2026),
error: 'x',
);
}
await store.saveNativeAuthContext(
username: 'user',
baseUrl: 'https://cloud',
);
await store.clear();
expect(storage.values, isEmpty);
});
});
}