269 lines
8.4 KiB
Dart
269 lines
8.4 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
|
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
|
import 'package:ikar/backend/api.dart';
|
|
import 'package:ikar/backend/modules/account.dart';
|
|
import 'package:ikar/core/protocol/opcode_map.dart';
|
|
import 'package:ikar/core/protocol/packet.dart';
|
|
import 'package:ikar/core/storage/app_database.dart';
|
|
import 'package:ikar/core/storage/token_storage.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class _Paths extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
|
_Paths(this.directory);
|
|
final String directory;
|
|
|
|
@override
|
|
Future<String?> getApplicationSupportPath() async => directory;
|
|
}
|
|
|
|
class _Api extends Api {
|
|
final requests = <(int, Map<dynamic, dynamic>)>[];
|
|
final responses = <int, Map<dynamic, dynamic>>{};
|
|
|
|
@override
|
|
SessionState get state => SessionState.online;
|
|
|
|
@override
|
|
Stream<SessionState> get stateStream => const Stream.empty();
|
|
|
|
@override
|
|
Future<Packet> sendRequest(
|
|
int opcode,
|
|
Map<dynamic, dynamic> payload, {
|
|
void Function()? beforeSend,
|
|
bool silent = false,
|
|
}) async {
|
|
beforeSend?.call();
|
|
requests.add((opcode, Map.of(payload)));
|
|
return Packet(
|
|
cmd: CmdType.ok,
|
|
opcode: opcode,
|
|
payload: responses[opcode] ?? <dynamic, dynamic>{},
|
|
);
|
|
}
|
|
}
|
|
|
|
const _profile = {
|
|
'contact': {
|
|
'id': 42,
|
|
'names': <dynamic>[
|
|
{'type': 'ONEME', 'firstName': 'New', 'lastName': 'User'},
|
|
],
|
|
},
|
|
};
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
const secureChannel = MethodChannel(
|
|
'plugins.it_nomads.com/flutter_secure_storage',
|
|
);
|
|
final secureValues = <String, String>{};
|
|
late _Api api;
|
|
late AccountModule account;
|
|
|
|
setUp(() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
secureValues.clear();
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(secureChannel, (call) async {
|
|
final args = call.arguments as Map;
|
|
final key = args['key'] as String;
|
|
switch (call.method) {
|
|
case 'write':
|
|
secureValues[key] = args['value'] as String;
|
|
return null;
|
|
case 'read':
|
|
return secureValues[key];
|
|
case 'delete':
|
|
secureValues.remove(key);
|
|
return null;
|
|
}
|
|
throw StateError('Unexpected storage method: ${call.method}');
|
|
});
|
|
final directory = Directory.systemTemp.createTempSync(
|
|
'qlyra_registration_test_',
|
|
);
|
|
final originalPaths = PathProviderPlatform.instance;
|
|
PathProviderPlatform.instance = _Paths(directory.path);
|
|
addTearDown(() async {
|
|
await AppDatabase.close();
|
|
PathProviderPlatform.instance = originalPaths;
|
|
directory.deleteSync(recursive: true);
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(secureChannel, null);
|
|
});
|
|
await AppDatabase.init();
|
|
api = _Api();
|
|
addTearDown(api.dispose);
|
|
account = AccountModule(api);
|
|
});
|
|
|
|
test(
|
|
'new account: OTP, registration, first login and restored login',
|
|
() async {
|
|
api.responses[Opcode.auth] = {
|
|
'tokenAttrs': {
|
|
'REGISTER': {'token': 'temporary-registration-token'},
|
|
},
|
|
};
|
|
final verified = await account.verifyCode('123456', 'sms-token');
|
|
expect(verified.isRegistration, isTrue);
|
|
expect(secureValues, isEmpty);
|
|
expect(await TokenStorage.getActiveAccountId(), isNull);
|
|
|
|
api.responses[Opcode.authConfirm] = {
|
|
'userToken': 42,
|
|
'profile': _profile,
|
|
'tokenType': 'LOGIN',
|
|
'token': 'registered-login-token',
|
|
};
|
|
final id = await account.completeRegistration(
|
|
token: verified.registerToken!,
|
|
firstName: 'New',
|
|
lastName: 'User',
|
|
photoId: 7,
|
|
);
|
|
expect(id, 42);
|
|
expect(api.requests.last.$1, Opcode.authConfirm);
|
|
expect(api.requests.last.$2, {
|
|
'token': 'temporary-registration-token',
|
|
'tokenType': 'REGISTER',
|
|
'firstName': 'New',
|
|
'lastName': 'User',
|
|
'photoId': 7,
|
|
'avatarType': 'PRESET_AVATAR',
|
|
});
|
|
expect(await TokenStorage.readActiveToken(), 'registered-login-token');
|
|
expect((await AppDatabase.loadProfile(id))?.firstName, 'New');
|
|
|
|
api.responses[Opcode.login] = {
|
|
'profile': _profile,
|
|
'token': 'rotated-login-token',
|
|
'time': 1700000000000,
|
|
};
|
|
final loggedIn = await account.login(accountId: id);
|
|
expect(loggedIn.profile.id, id);
|
|
expect(account.isLoggedIn, isTrue);
|
|
expect(
|
|
api.requests.firstWhere((r) => r.$1 == Opcode.login).$2['token'],
|
|
'registered-login-token',
|
|
);
|
|
expect(await TokenStorage.readActiveToken(), 'rotated-login-token');
|
|
|
|
final restored = AccountModule(api);
|
|
await restored.login();
|
|
expect(
|
|
api.requests.lastWhere((r) => r.$1 == Opcode.login).$2['token'],
|
|
'rotated-login-token',
|
|
);
|
|
expect(restored.isLoggedIn, isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'REGISTER token with account ID does not become an active session',
|
|
() async {
|
|
api.responses[Opcode.auth] = {
|
|
'profile': _profile,
|
|
'tokenAttrs': {
|
|
'REGISTER': {'token': 'registration-only'},
|
|
},
|
|
};
|
|
final result = await account.verifyCode('123456', 'sms-token');
|
|
expect(result.isRegistration, isTrue);
|
|
expect(secureValues, isEmpty);
|
|
expect(await TokenStorage.getActiveAccountId(), isNull);
|
|
expect(await AppDatabase.loadAllProfiles(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('existing account still persists LOGIN token after OTP', () async {
|
|
api.responses[Opcode.auth] = {
|
|
'profile': _profile,
|
|
'tokenAttrs': {
|
|
'LOGIN': {'token': 'existing-login-token'},
|
|
},
|
|
};
|
|
final result = await account.verifyCode('123456', 'sms-token');
|
|
expect(result.isRegistration, isFalse);
|
|
expect(await TokenStorage.readActiveToken(), 'existing-login-token');
|
|
expect((await AppDatabase.loadProfile(42))?.firstName, 'New');
|
|
});
|
|
|
|
for (final invalidToken in [null, '', ' ', 123]) {
|
|
test('registration rejects invalid login token: $invalidToken', () async {
|
|
api.responses[Opcode.authConfirm] = {
|
|
'profile': _profile,
|
|
'tokenType': 'LOGIN',
|
|
'token': invalidToken,
|
|
};
|
|
await expectLater(
|
|
account.completeRegistration(token: 'register', firstName: 'New'),
|
|
throwsFormatException,
|
|
);
|
|
expect(secureValues, isEmpty);
|
|
expect(await TokenStorage.getActiveAccountId(), isNull);
|
|
expect(await AppDatabase.loadAllProfiles(), isEmpty);
|
|
});
|
|
}
|
|
|
|
test(
|
|
'registration rejects a REGISTER token in the confirmation response',
|
|
() async {
|
|
api.responses[Opcode.authConfirm] = {
|
|
'profile': _profile,
|
|
'tokenType': 'REGISTER',
|
|
'token': 'temporary',
|
|
};
|
|
await expectLater(
|
|
account.completeRegistration(token: 'register', firstName: 'New'),
|
|
throwsFormatException,
|
|
);
|
|
expect(secureValues, isEmpty);
|
|
expect(await TokenStorage.getActiveAccountId(), isNull);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'login rejects empty explicit and stored tokens before network request',
|
|
() async {
|
|
await expectLater(
|
|
account.login(accountId: 42, token: ''),
|
|
throwsStateError,
|
|
);
|
|
await TokenStorage.saveToken(' ', 42);
|
|
await expectLater(account.login(accountId: 42), throwsStateError);
|
|
expect(api.requests, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('resend requests a new OTP token from the server', () async {
|
|
api.responses[Opcode.authRequest] = {'token': 'fresh-sms-token'};
|
|
final result = await account.resendCode('+7 (999) 000-00-00');
|
|
expect(result.token, 'fresh-sms-token');
|
|
expect(api.requests.single.$1, Opcode.authRequest);
|
|
expect(api.requests.single.$2, {
|
|
'phone': '+79990000000',
|
|
'type': 'RESEND',
|
|
'language': 'ru',
|
|
});
|
|
});
|
|
|
|
test('empty LOGIN token does not hide a valid registration token', () {
|
|
final result = VerifyCodeResult(
|
|
payload: {
|
|
'tokenAttrs': {
|
|
'LOGIN': {'token': ''},
|
|
'REGISTER': {'token': 'registration-only'},
|
|
},
|
|
},
|
|
);
|
|
expect(result.loginToken, isNull);
|
|
expect(result.isRegistration, isTrue);
|
|
});
|
|
}
|