twonly-app/test/services/key_verification_service_test.dart
otsmr 96eddd7480
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Fix: Shared contacts now correctly show the blue verification badge
2026-06-16 11:32:39 +02:00

693 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/key_verification.dao.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/userdata.model.dart';
import 'package:twonly/src/services/api.service.dart';
import 'package:twonly/src/services/user.service.dart';
void main() {
if (!Platform.isMacOS) {
return;
}
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
await locator.reset();
locator
..registerSingleton<TwonlyDB>(
TwonlyDB.forTesting(
DatabaseConnection(
NativeDatabase.memory(),
closeStreamsSynchronously: true,
),
),
)
..registerSingleton<UserService>(UserService())
..registerSingleton<ApiService>(ApiService());
// isUserDiscoveryEnabled defaults to false, so no Rust bridge calls happen
// in addKeyVerification / deleteKeyVerification.
userService.currentUser = UserData(
userId: 1,
username: 'me',
displayName: 'Me',
subscriptionPlan: 'Free',
currentSetupPage: null,
appVersion: 100,
);
userService.isUserCreated = true;
AppEnvironment.initTesting();
});
tearDown(() async {
await twonlyDB.close();
});
// ─── Helpers ────────────────────────────────────────────────────────────────
Future<void> insertContact(int userId, {String? username}) async {
await twonlyDB.contactsDao.insertContact(
ContactsCompanion.insert(
userId: Value(userId),
username: username ?? 'user$userId',
),
);
}
Future<void> addDirectVerification(
int contactId,
VerificationType type,
) async {
await twonlyDB.keyVerificationDao.addKeyVerification(contactId, type);
}
Future<void> addSharedVerification(
int contactId,
int sharedByContactId,
) async {
await twonlyDB.keyVerificationDao.addKeyVerification(
contactId,
VerificationType.contactSharedByVerified,
verifiedBy: sharedByContactId,
);
}
// ─── Verification Tokens ────────────────────────────────────────────────────
group('KeyVerificationDao Verification Tokens', () {
test(
'insertVerificationToken stores token; getRecentVerificationTokens returns it',
() async {
final token = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]);
await twonlyDB.keyVerificationDao.insertVerificationToken(token);
final tokens = await twonlyDB.keyVerificationDao
.getRecentVerificationTokens();
expect(tokens.length, 1);
expect(tokens.first.token, token);
},
);
test('multiple tokens are all returned when recent', () async {
final t1 = Uint8List.fromList(List.generate(16, (i) => i));
final t2 = Uint8List.fromList(List.generate(16, (i) => i + 16));
await twonlyDB.keyVerificationDao.insertVerificationToken(t1);
await twonlyDB.keyVerificationDao.insertVerificationToken(t2);
final tokens = await twonlyDB.keyVerificationDao
.getRecentVerificationTokens();
expect(tokens.length, 2);
});
});
// ─── Direct Verification ────────────────────────────────────────────────────
group('KeyVerificationDao Direct Verification', () {
test(
'addKeyVerification stores an entry; getContactVerification returns it',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
final verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
expect(verifications.length, 1);
expect(verifications.first.type, VerificationType.secretQrToken);
expect(verifications.first.contactId, 10);
expect(verifications.first.verifiedBy, isNull);
},
);
test('isContactVerified returns true for secretQrToken', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
});
test('isContactVerified returns true for qrScanned', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.qrScanned);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
});
test('isContactVerified returns true for link', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.link);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
});
test(
'isContactVerified returns false when no verification exists',
() async {
await insertContact(10);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), false);
},
);
test('multiple direct verifications are stored independently', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
final verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
expect(verifications.length, 2);
});
});
// ─── Shared / Transitive Verification (Blue Badge) ──────────────────────────
group('KeyVerificationDao Transitive Trust (Blue Badge)', () {
// Scenario setup:
// alice (userId=2) may or may not be directly verified
// bob (userId=3) shared by alice (contactSharedByVerified, verifiedBy=2)
//
// Rule: bob's shared verification only "counts" if alice is herself verified.
test(
'isContactVerified is false when sharer (alice) is NOT verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
// Bob shared by Alice, but Alice is not verified
await addSharedVerification(3, 2);
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), false);
},
);
test(
'isContactVerified is true (blue badge) when sharer (alice) IS verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
// Alice verified directly
await addDirectVerification(2, VerificationType.secretQrToken);
// Bob shared by verified Alice
await addSharedVerification(3, 2);
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
},
);
test(
'isContactVerified updates reactively when sharer becomes verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addSharedVerification(3, 2);
// Bob not yet verified (alice not verified)
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), false);
// Alice gets verified
await addDirectVerification(2, VerificationType.qrScanned);
// Now Bob is transitively verified
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
},
);
test(
'direct verification takes precedence regardless of sharer state',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
// Bob has both a direct and a shared (unverified sharer) verification
await addDirectVerification(3, VerificationType.link);
await addSharedVerification(3, 2); // alice not verified
// Direct verification makes bob verified
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
},
);
});
// ─── watchContactVerification ───────────────────────────────────────────────
group('KeyVerificationDao watchContactVerification', () {
test(
'emits the direct verification entry with no verifier contact',
() async {
await insertContact(10, username: 'alice');
await addDirectVerification(10, VerificationType.secretQrToken);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(10)
.first;
expect(entries.length, 1);
final (kv, verifierContact) = entries.first;
expect(kv.type, VerificationType.secretQrToken);
expect(verifierContact, isNull);
},
);
test(
'filters out shared verification when sharer is NOT verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addSharedVerification(3, 2); // alice not verified
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(3)
.first;
expect(entries, isEmpty);
},
);
test(
'returns shared verification entry with verifier contact when sharer IS verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(3)
.first;
expect(entries.length, 1);
final (kv, verifierContact) = entries.first;
expect(kv.type, VerificationType.contactSharedByVerified);
expect(verifierContact, isNotNull);
expect(verifierContact!.username, 'alice');
},
);
test(
'emits mixed entries: direct (no filter) + shared (filtered by verifier state)',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'charlie');
await insertContact(4, username: 'bob');
// Bob has a direct verification
await addDirectVerification(4, VerificationType.link);
// Bob is also shared by Alice (unverified) should NOT appear
await addSharedVerification(4, 2);
// Bob is also shared by Charlie (verified) SHOULD appear
await addDirectVerification(3, VerificationType.qrScanned);
await addSharedVerification(4, 3);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(4)
.first;
// Expect: direct + shared-by-charlie (2 entries, shared-by-alice filtered)
expect(entries.length, 2);
final types = entries.map((e) => e.$1.type).toSet();
expect(types, contains(VerificationType.link));
expect(types, contains(VerificationType.contactSharedByVerified));
},
);
test('emits empty list for contact with no verifications', () async {
await insertContact(10);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(10)
.first;
expect(entries, isEmpty);
});
});
// ─── getFirstVerificationTypeByContacts ─────────────────────────────────────
group('KeyVerificationDao getFirstVerificationTypeByContacts', () {
test(
'returns a map with the earliest verification type per contact',
() async {
await insertContact(10);
await insertContact(20);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(20, VerificationType.link);
final map = await twonlyDB.keyVerificationDao
.getFirstVerificationTypeByContacts();
expect(map[10], VerificationType.secretQrToken);
expect(map[20], VerificationType.link);
},
);
test('returns an empty map when no verifications exist', () async {
final map = await twonlyDB.keyVerificationDao
.getFirstVerificationTypeByContacts();
expect(map, isEmpty);
});
test(
'returns only the first verification type when multiple exist',
() async {
await insertContact(10);
// Insert two types for the same contact
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
final map = await twonlyDB.keyVerificationDao
.getFirstVerificationTypeByContacts();
// Only the first-inserted (earliest createdAt) should be in the map
expect(map.length, 1);
expect(map.containsKey(10), true);
// The first inserted was secretQrToken
expect(map[10], VerificationType.secretQrToken);
},
);
});
// ─── deleteKeyVerification ───────────────────────────────────────────────────
group('KeyVerificationDao deleteKeyVerification', () {
test('removes all verifications for a contact', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
expect(
await twonlyDB.keyVerificationDao.getContactVerification(10),
hasLength(2),
);
await twonlyDB.keyVerificationDao.deleteKeyVerification(10);
expect(
await twonlyDB.keyVerificationDao.getContactVerification(10),
isEmpty,
);
});
test(
'isContactVerified returns false after deleteKeyVerification',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
await twonlyDB.keyVerificationDao.deleteKeyVerification(10);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), false);
},
);
test('deleting one contact does not affect other contacts', () async {
await insertContact(10);
await insertContact(20);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(20, VerificationType.qrScanned);
await twonlyDB.keyVerificationDao.deleteKeyVerification(10);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), false);
expect(await twonlyDB.keyVerificationDao.isContactVerified(20), true);
});
test(
'deleting sharer invalidates transitive trust for shared contacts',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
// Remove Alice's verification
await twonlyDB.keyVerificationDao.deleteKeyVerification(2);
// Bob's transitive trust should now be invalid
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), false);
},
);
});
// ─── deleteKeyVerificationById ───────────────────────────────────────────────
group('KeyVerificationDao deleteKeyVerificationById', () {
test('removes only the specified verification entry', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
var verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
expect(verifications.length, 2);
final idToDelete = verifications.first.verificationId;
await twonlyDB.keyVerificationDao.deleteKeyVerificationById(
idToDelete,
10,
);
verifications = await twonlyDB.keyVerificationDao.getContactVerification(
10,
);
expect(verifications.length, 1);
expect(verifications.first.verificationId, isNot(idToDelete));
});
test(
'isContactVerified remains true if another direct verification exists',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
final verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
await twonlyDB.keyVerificationDao.deleteKeyVerificationById(
verifications.first.verificationId,
10,
);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
},
);
test(
'isContactVerified returns false after deleting the last verification',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
final verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
await twonlyDB.keyVerificationDao.deleteKeyVerificationById(
verifications.first.verificationId,
10,
);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), false);
},
);
});
// ─── watchAllGroupMembersVerified ───────────────────────────────────────────
group('KeyVerificationDao watchAllGroupMembersVerified', () {
const groupId = 'test-group-abc';
Future<void> setupGroup(List<int> memberIds) async {
await twonlyDB.groupsDao.createNewGroup(
GroupsCompanion.insert(
groupId: groupId,
groupName: 'Trust Test Group',
),
);
for (final id in memberIds) {
await twonlyDB.groupsDao.insertOrUpdateGroupMember(
GroupMembersCompanion.insert(groupId: groupId, contactId: id),
);
}
}
test('notTrusted when no member is verified', () async {
await insertContact(10);
await insertContact(20);
await setupGroup([10, 20]);
final status = await twonlyDB.keyVerificationDao
.watchAllGroupMembersVerified(groupId)
.first;
expect(status, VerificationStatus.notTrusted);
});
test('trusted when all members are directly verified', () async {
await insertContact(10);
await insertContact(20);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(20, VerificationType.qrScanned);
await setupGroup([10, 20]);
final status = await twonlyDB.keyVerificationDao
.watchAllGroupMembersVerified(groupId)
.first;
expect(status, VerificationStatus.trusted);
});
test('notTrusted for empty group', () async {
await twonlyDB.groupsDao.createNewGroup(
GroupsCompanion.insert(groupId: groupId, groupName: 'Empty Group'),
);
final status = await twonlyDB.keyVerificationDao
.watchAllGroupMembersVerified(groupId)
.first;
expect(status, VerificationStatus.notTrusted);
});
test(
'single member group trusted when that member is verified',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.link);
await setupGroup([10]);
final status = await twonlyDB.keyVerificationDao
.watchAllGroupMembersVerified(groupId)
.first;
expect(status, VerificationStatus.trusted);
},
);
});
// ─── Full Transitive Trust Scenario ─────────────────────────────────────────
group('KeyVerificationService Full Transitive Trust Scenario', () {
// Simulates the complete "blue badge" flow:
// 1. Alice (2) is directly verified by me via QR code.
// 2. Alice shares Bob (3) addKeyVerification(3, contactSharedByVerified, verifiedBy: 2)
// 3. Bob should receive the blue verification badge (isContactVerified = true)
// because Alice (the sharer) is herself verified.
//
// 4. Charlie (4) is shared by Dave (5) who is NOT verified.
// 5. Charlie should NOT receive a badge.
//
// 6. Eve (6) is shared by both Dave (5, unverified) and Alice (2, verified).
// 7. Eve should receive a badge (at least one verified sharer).
test('bob gets blue badge because alice (sharer) is verified', () async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
});
test(
'charlie gets no badge because dave (sharer) is NOT verified',
() async {
await insertContact(4, username: 'charlie');
await insertContact(5, username: 'dave');
await addSharedVerification(4, 5);
expect(await twonlyDB.keyVerificationDao.isContactVerified(4), false);
},
);
test(
'eve gets blue badge because alice (one of her sharers) is verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(5, username: 'dave');
await insertContact(6, username: 'eve');
await addDirectVerification(2, VerificationType.secretQrToken);
// Dave is NOT verified
await addSharedVerification(6, 5); // dave shares eve (does not count)
await addSharedVerification(6, 2); // alice shares eve (counts!)
expect(await twonlyDB.keyVerificationDao.isContactVerified(6), true);
},
);
test(
'watchContactVerification shows alice as verifier for bob (blue badge)',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(3)
.first;
expect(entries.length, 1);
final (kv, verifierContact) = entries.first;
expect(kv.type, VerificationType.contactSharedByVerified);
expect(verifierContact?.username, 'alice');
},
);
test(
'watchContactVerification shows no entries for charlie (sharer unverified)',
() async {
await insertContact(4, username: 'charlie');
await insertContact(5, username: 'dave');
await addSharedVerification(4, 5);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(4)
.first;
expect(entries, isEmpty);
},
);
test('removing alice revokes bob blue badge transitively', () async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
// Confirm bob is verified
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
// Revoke alice's verification
await twonlyDB.keyVerificationDao.deleteKeyVerification(2);
// Bob should lose the blue badge
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), false);
// watchContactVerification should also be empty for bob
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(3)
.first;
expect(entries, isEmpty);
});
});
}