Update Qlyra application
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final manifest = File('android/app/src/main/AndroidManifest.xml');
|
||||
final matrix = File('tool/android_permissions.json');
|
||||
|
||||
test('every Android manifest permission is present in the policy matrix', () {
|
||||
final declared = RegExp(r'<uses-permission\s+android:name="([^"]+)"')
|
||||
.allMatches(manifest.readAsStringSync())
|
||||
.map((match) => match.group(1)!)
|
||||
.toSet();
|
||||
final entries = (jsonDecode(matrix.readAsStringSync()) as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
final documented = entries
|
||||
.map((entry) => entry['permission'] as String)
|
||||
.toSet();
|
||||
|
||||
expect(entries.length, documented.length);
|
||||
expect(documented, declared);
|
||||
for (final entry in entries) {
|
||||
expect((entry['owner'] as String).trim(), isNotEmpty);
|
||||
expect((entry['feature'] as String).trim(), isNotEmpty);
|
||||
expect((entry['trigger'] as String).trim(), isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime-sensitive permissions require a user action', () {
|
||||
final entries = (jsonDecode(matrix.readAsStringSync()) as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
final byPermission = {
|
||||
for (final entry in entries) entry['permission'] as String: entry,
|
||||
};
|
||||
final sensitive = {
|
||||
'android.permission.REQUEST_INSTALL_PACKAGES',
|
||||
'android.permission.CAMERA',
|
||||
'android.permission.ACCESS_FINE_LOCATION',
|
||||
'android.permission.ACCESS_COARSE_LOCATION',
|
||||
'android.permission.RECORD_AUDIO',
|
||||
'android.permission.BLUETOOTH_CONNECT',
|
||||
'android.permission.BLUETOOTH_ADVERTISE',
|
||||
'android.permission.BLUETOOTH_SCAN',
|
||||
'android.permission.POST_NOTIFICATIONS',
|
||||
'android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS',
|
||||
'android.permission.USE_FULL_SCREEN_INTENT',
|
||||
'android.permission.READ_EXTERNAL_STORAGE',
|
||||
'android.permission.READ_MEDIA_IMAGES',
|
||||
'android.permission.READ_MEDIA_VIDEO',
|
||||
'android.permission.READ_MEDIA_VISUAL_USER_SELECTED',
|
||||
'android.permission.READ_CONTACTS',
|
||||
};
|
||||
|
||||
for (final permission in sensitive) {
|
||||
expect(byPermission[permission]?['trigger'], 'user_action');
|
||||
}
|
||||
});
|
||||
|
||||
test('startup paths do not request notification or full-screen access', () {
|
||||
final pushSource = File(
|
||||
'lib/core/push/push_service.dart',
|
||||
).readAsStringSync();
|
||||
final mainSource = File('lib/main.dart').readAsStringSync();
|
||||
|
||||
final initBody = RegExp(
|
||||
r'Future<void> init\([\s\S]*?Future<bool> requestPermissionFromUser',
|
||||
).firstMatch(pushSource)?.group(0);
|
||||
expect(initBody, isNotNull);
|
||||
expect(initBody, isNot(contains('requestPermission()')));
|
||||
expect(mainSource, isNot(contains('_ensureFullScreenIntentPermission')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const qualityWorkflows = [
|
||||
'.github/workflows/flutter-dev.yml',
|
||||
'.github/workflows/flutter-main.yml',
|
||||
'.github/workflows/build-android.yml',
|
||||
'.github/workflows/build-android-fcm.yml',
|
||||
];
|
||||
|
||||
test('build workflows run formatting, analysis and tests', () {
|
||||
for (final path in qualityWorkflows) {
|
||||
final workflow = File(path).readAsStringSync();
|
||||
expect(
|
||||
workflow,
|
||||
contains('dart format --output=none --set-exit-if-changed lib test'),
|
||||
);
|
||||
expect(workflow, contains('flutter analyze --no-fatal-infos lib test'));
|
||||
expect(workflow, contains('flutter test'));
|
||||
}
|
||||
});
|
||||
|
||||
test('pull request CI produces coverage', () {
|
||||
for (final path in [
|
||||
'.github/workflows/flutter-dev.yml',
|
||||
'.github/workflows/flutter-main.yml',
|
||||
]) {
|
||||
final workflow = File(path).readAsStringSync();
|
||||
expect(workflow, contains('flutter test --coverage'));
|
||||
expect(workflow, contains('coverage/lcov.info'));
|
||||
}
|
||||
});
|
||||
|
||||
test('dependency and Android smoke workflows stay enabled', () {
|
||||
final security = File(
|
||||
'.github/workflows/osv-scanner.yml',
|
||||
).readAsStringSync();
|
||||
final smoke = File(
|
||||
'.github/workflows/android-smoke.yml',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(security, contains('osv-scanner-reusable.yml@v2.5.0'));
|
||||
expect(security, contains('--lockfile=./pubspec.lock'));
|
||||
expect(smoke, contains('android-emulator-runner@v2'));
|
||||
expect(smoke, contains('adb shell pidof ru.qlyra.app'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:qlyra/core/utils/ip_lookup_service.dart';
|
||||
import 'package:qlyra/frontend/screens/profile/devices_screen.dart';
|
||||
|
||||
void main() {
|
||||
test('devices screen can be constructed with the protected lookup flow', () {
|
||||
expect(const DevicesScreen(), isA<DevicesScreen>());
|
||||
});
|
||||
|
||||
test('normalizes an ipwho.is response', () {
|
||||
final details = IpLookupDetails.fromIpWhoIs({
|
||||
'success': true,
|
||||
'city': 'Moscow',
|
||||
'country': 'Russia',
|
||||
'connection': {'asn': 64500, 'org': 'Example Org', 'isp': 'Example ISP'},
|
||||
'security': {'mobile': true, 'proxy': false},
|
||||
'timezone': {'id': 'Europe/Moscow'},
|
||||
});
|
||||
|
||||
expect(details.city, 'Moscow');
|
||||
expect(details.country, 'Russia');
|
||||
expect(details.isp, 'Example ISP');
|
||||
expect(details.network, 'AS64500 Example Org');
|
||||
expect(details.mobile, isTrue);
|
||||
expect(details.proxy, isFalse);
|
||||
expect(details.timezone, 'Europe/Moscow');
|
||||
});
|
||||
|
||||
test('rejects a failed provider response', () {
|
||||
expect(
|
||||
() => IpLookupDetails.fromIpWhoIs({
|
||||
'success': false,
|
||||
'message': 'Invalid IP',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:qlyra/core/utils/update_checker.dart';
|
||||
import 'package:qlyra/core/utils/update_installer.dart';
|
||||
|
||||
void main() {
|
||||
const qlyra = AppUpdateArtifact(
|
||||
name: 'qlyra.apk',
|
||||
url: 'https://example.test/qlyra.apk',
|
||||
flavor: 'qlyra',
|
||||
abi: 'universal',
|
||||
sizeBytes: 100,
|
||||
sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
);
|
||||
const onemeArm64 = AppUpdateArtifact(
|
||||
name: 'oneme-arm64.apk',
|
||||
url: 'https://example.test/oneme-arm64.apk',
|
||||
flavor: 'oneme',
|
||||
abi: 'arm64-v8a',
|
||||
sizeBytes: 100,
|
||||
sha256: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
);
|
||||
const onemeUniversal = AppUpdateArtifact(
|
||||
name: 'oneme.apk',
|
||||
url: 'https://example.test/oneme.apk',
|
||||
flavor: 'oneme',
|
||||
abi: 'universal',
|
||||
sizeBytes: 100,
|
||||
sha256: 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
);
|
||||
|
||||
test('maps package identities to independent Argus slugs', () {
|
||||
expect(UpdateChecker.manifestSlugForPackageName('ru.qlyra.app'), 'qlyra');
|
||||
expect(UpdateChecker.manifestSlugForPackageName('ru.oneme.app'), 'oneme');
|
||||
});
|
||||
|
||||
test('selects only an artifact for the installed flavor', () {
|
||||
expect(
|
||||
UpdateInstaller.selectArtifact(
|
||||
[qlyra, onemeArm64, onemeUniversal],
|
||||
'ru.qlyra.app',
|
||||
['arm64-v8a'],
|
||||
),
|
||||
same(qlyra),
|
||||
);
|
||||
expect(
|
||||
UpdateInstaller.selectArtifact(
|
||||
[qlyra, onemeArm64, onemeUniversal],
|
||||
'ru.oneme.app',
|
||||
['arm64-v8a'],
|
||||
),
|
||||
same(onemeArm64),
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to a universal artifact of the same flavor', () {
|
||||
expect(
|
||||
UpdateInstaller.selectArtifact(
|
||||
[qlyra, onemeUniversal],
|
||||
'ru.oneme.app',
|
||||
['x86_64'],
|
||||
),
|
||||
same(onemeUniversal),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not cross-install another flavor', () {
|
||||
expect(
|
||||
UpdateInstaller.selectArtifact([qlyra], 'ru.oneme.app', ['arm64-v8a']),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('verifies artifact size and SHA-256', () async {
|
||||
final directory = await Directory.systemTemp.createTemp('qlyra-update-');
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final file = File('${directory.path}/update.apk');
|
||||
final bytes = [1, 2, 3, 4];
|
||||
await file.writeAsBytes(bytes);
|
||||
final artifact = AppUpdateArtifact(
|
||||
name: 'update.apk',
|
||||
url: 'https://example.test/update.apk',
|
||||
flavor: 'qlyra',
|
||||
abi: 'universal',
|
||||
sizeBytes: bytes.length,
|
||||
sha256: sha256.convert(bytes).toString(),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
UpdateInstaller.verifyArtifact(file, artifact),
|
||||
completes,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an artifact with a different digest', () async {
|
||||
final directory = await Directory.systemTemp.createTemp('qlyra-update-');
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final file = File('${directory.path}/update.apk');
|
||||
await file.writeAsBytes([1, 2, 3, 4]);
|
||||
const artifact = AppUpdateArtifact(
|
||||
name: 'update.apk',
|
||||
url: 'https://example.test/update.apk',
|
||||
flavor: 'qlyra',
|
||||
abi: 'universal',
|
||||
sizeBytes: 4,
|
||||
sha256:
|
||||
'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
UpdateInstaller.verifyArtifact(file, artifact),
|
||||
throwsA(isA<UpdateIntegrityException>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:qlyra/frontend/screens/webapp/web_app_security_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('WebAppSecurityPolicy', () {
|
||||
test('requires an HTTPS launch URL', () {
|
||||
expect(
|
||||
() => WebAppSecurityPolicy.fromLaunchUrl('http://mini.invalid/app'),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => WebAppSecurityPolicy.fromLaunchUrl('https://mini.invalid/app'),
|
||||
returnsNormally,
|
||||
);
|
||||
});
|
||||
|
||||
test('allows only explicitly listed top-level origins', () {
|
||||
final policy = WebAppSecurityPolicy.fromLaunchUrl(
|
||||
'https://mini.invalid/start',
|
||||
additionalOrigins: const ['https://auth.invalid/login'],
|
||||
);
|
||||
|
||||
expect(
|
||||
policy.allowsNavigation(Uri.parse('https://mini.invalid/next')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
policy.allowsNavigation(Uri.parse('https://auth.invalid/return')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
policy.allowsNavigation(Uri.parse('https://other.invalid/')),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
policy.allowsNavigation(Uri.parse('http://mini.invalid/')),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('denies permission requests from unlisted or inactive origins', () {
|
||||
final policy = WebAppSecurityPolicy.fromLaunchUrl(
|
||||
'https://mini.invalid/app',
|
||||
);
|
||||
|
||||
expect(
|
||||
policy.allowsPermission(
|
||||
Uri.parse('https://mini.invalid'),
|
||||
Uri.parse('https://mini.invalid/page'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
policy.allowsPermission(
|
||||
Uri.parse('https://other.invalid'),
|
||||
Uri.parse('https://mini.invalid/page'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
policy.allowsPermission(
|
||||
Uri.parse('https://mini.invalid'),
|
||||
Uri.parse('https://other.invalid/page'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -91,6 +91,16 @@ void main() {
|
||||
'{"requestId":"r4","text":"hi"}',
|
||||
false,
|
||||
);
|
||||
for (final method in [
|
||||
'WebAppRequestPhone',
|
||||
'WebAppDownloadFile',
|
||||
'WebAppOpenCodeReader',
|
||||
'WebAppBiometryRequestAccess',
|
||||
'WebAppBiometryRequestAuth',
|
||||
'WebAppBiometryUpdateToken',
|
||||
]) {
|
||||
await bridge.handleEvent(method, '{"requestId":"blocked"}', false);
|
||||
}
|
||||
expect(sent, isEmpty);
|
||||
|
||||
bridge.registerGesture();
|
||||
|
||||
@@ -18,14 +18,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('hands app schemes over to the app', () {
|
||||
for (final scheme in [
|
||||
'max',
|
||||
'MAX',
|
||||
'qlyra',
|
||||
'tel',
|
||||
'mailto',
|
||||
'intent',
|
||||
]) {
|
||||
for (final scheme in ['max', 'MAX', 'qlyra', 'tel', 'mailto', 'intent']) {
|
||||
expect(leavesWebView(scheme), isTrue, reason: scheme);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user