Files
sepot/app/test/groups_admin_flow_test.dart
T
Julius Joska 5246a97db2 feat(app): redesigned interface, polls, drafts, forwarding and mentions
A new look across the app and a set of everyday messaging features.

Interface
- Home screen with Chats, Contacts and Settings tabs, a large title,
  search across chats and message text (diacritics-insensitive), filters
  and pinned chats.
- Conversation view with a translucent header and composer, grouped
  bubbles, author avatars and colours in groups, swipe to reply, an
  "N new messages" marker, a jump-to-latest button and in-chat search.
- Contact and group profiles: verification, shared photos, groups in
  common, disappearing messages, mute, block; members, admin badges,
  adding people, rename and leave for groups.
- Full-screen photo viewer that pages through the whole conversation.
- A stable avatar colour per person and a clearly visible read state.

Messaging
- Polls in chats and groups, single or multiple choice, with votes that
  can be changed or withdrawn; older clients see them as text.
- Drafts survive leaving a chat and show in the chat list.
- Forwarding of messages, photos, files and voice messages.
- Notes to self: a local-only, encrypted chat that never reaches the
  relay.
- @mentions in groups that notify even when the group is muted.
- Typing indicators sent as ephemeral messages, opt-in and mutual like
  read receipts.
- Delivery and read times per message and per group member.
- Profile photos, cropped, resized and stripped of metadata, delivered
  encrypted to contacts.
- Voice message playback at 1.5x and 2x.
- Call buttons in 1:1 chats and call log entries.

Fixes
- Muted groups no longer notify: muting was checked against the
  author's chat instead of the group.
- A blocked contact could unblock itself by changing its delivery
  address.
- Read receipts no longer regress to delivered when a late delivery
  receipt arrives.
- A message marked as failed corrects itself once it is delivered.
- Double ticks in groups now mean delivered to every member.
2026-10-03 22:44:53 +02:00

413 lines
14 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/errors.dart';
import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/models/identity.dart';
/// Správci skupin end-to-end přes MessagingService: skutečné XEdDSA
/// podpisy, skutečné úložiště, relay nahrazený přímým předáním obsahu mezi
/// „telefony" (debugSendTap). Pokrývá tvorbu a příjem `gop`, legacy stín
/// pro v1 klienty, kompatibilní režim a resync po mezeře / rozchodu.
///
/// Potřebuje nativní libsignal i sqlcipher (jako groups_security_test).
void main() {
final rng = Random.secure();
final tmpDirs = <Directory>[];
final dbs = <CryptoDb>[];
/// Odeslaný obsah čekající na doručení (kdo, komu, co).
final outbox = <({String from, String to, Map<String, dynamic> content})>[];
setUpAll(() async => SepotCrypto.init());
tearDown(() {
outbox.clear();
for (final db in dbs) {
db.close();
}
for (final d in tmpDirs) {
d.deleteSync(recursive: true);
}
dbs.clear();
tmpDirs.clear();
});
Future<_Phone> phone(String name) async {
final tmp = Directory.systemTemp.createTempSync('sepot_admini');
tmpDirs.add(tmp);
final dek = Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
final db = CryptoDb.open(path: '${tmp.path}/app.db', dek: dek);
dbs.add(db);
final storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
..debugAttachDb(db);
final service = MessagingService(storage: storage);
final identity = await LocalIdentity.create(name);
service.debugSetIdentity(identity);
final p = _Phone(name, identity, storage, service);
service.debugSendTap = (to, content) => outbox.add((
from: p.fp,
to: to,
// Přes JSON jako na drátu — žádné sdílené objekty mezi telefony.
content: jsonDecode(jsonEncode(content)) as Map<String, dynamic>,
));
return p;
}
/// Každý zná každého jako kontakt s routingem (jako po výměně QR).
Future<void> introduce(List<_Phone> phones) async {
for (final p in phones) {
for (final q in phones) {
if (p == q) continue;
await p.storage.saveContact(Contact.fromPublicIdentity(
q.identity.publicIdentity.withRouting(
accountId: 'a' * 32,
deliveryAddress: Uint8List(32),
),
));
}
}
}
/// Doručí frontu (FIFO) příjemcům ze seznamu; ostatní zahodí.
Future<void> deliver(List<_Phone> phones,
{bool Function(Map<String, dynamic>)? where}) async {
final byFp = {for (final p in phones) p.fp: p};
final batch = [...outbox];
outbox.clear();
for (final m in batch) {
if (where != null && !where(m.content)) {
outbox.add(m);
continue;
}
await byFp[m.to]?.service.debugHandleGroupControl(m.from, m.content);
}
}
List<Map<String, dynamic>> sentTo(_Phone p, String type) => [
for (final m in outbox)
if (m.to == p.fp && m.content['t'] == type) m.content,
];
test('založení: v2 člen zná správce hned, v1 dostane obyčejný ginv',
() async {
final a = await phone('Alice');
final b = await phone('Bob');
await introduce([a, b]);
final contactB = (await a.storage.loadContacts()).single;
final g = await a.service.createGroup('Chata', [contactB]);
final ginv = sentTo(b, 'ginv').single;
// Tvar, který zpracuje v1 klient: v:1, gid, gname, roster se mnou i s ním.
expect(ginv['v'], 1);
expect(ginv['gname'], 'Chata');
expect((ginv['roster'] as List).map((e) => e['f']),
containsAll([a.fp, b.fp]));
expect(ginv['gop'], isA<Map>(), reason: 'vnořený podepsaný stav pro v2');
expect(ginv['gv'], 2);
await deliver([a, b]);
final gb = (await b.storage.loadGroup(g.id))!;
expect(gb.admins, [a.fp]);
expect(gb.epoch, 0);
expect(gb.provisional, isFalse, reason: 'stav je podepsaný');
});
group('skupina A (správce), B, C', () {
late _Phone a, b, c;
late String gid;
setUp(() async {
outbox.clear();
a = await phone('Alice');
b = await phone('Bob');
c = await phone('Cyril');
await introduce([a, b, c]);
final contacts = await a.storage.loadContacts();
gid = (await a.service.createGroup('Chata', contacts)).id;
await deliver([a, b, c]);
});
Future<Group> load(_Phone p) async => (await p.storage.loadGroup(gid))!;
test('odebrání: B aplikuje, C se dozví, že už není členem', () async {
await a.service.removeGroupMember(await load(a), c.fp);
expect(sentTo(c, 'ginv'), hasLength(1),
reason: 'odebraný dostane výsledný stav taky');
await deliver([a, b, c]);
expect((await load(b)).memberIds, isNot(contains(c.fp)));
expect((await load(b)).epoch, 1);
expect((await load(c)).left, isTrue);
});
test('povýšení a přejmenování novým správcem projde u ostatních',
() async {
await a.service.setGroupAdmin(await load(a), b.fp, true);
await deliver([a, b, c]);
expect((await load(c)).admins, containsAll([a.fp, b.fp]));
await b.service.renameGroup(await load(b), 'Chata 2026');
await deliver([a, b, c]);
expect((await load(a)).name, 'Chata 2026');
expect((await load(c)).name, 'Chata 2026');
expect((await load(c)).epoch, 2);
});
test('NE-SPRÁVCE: B podepsaný gop se nepostaví a podvržený se odmítne',
() async {
await expectLater(b.service.renameGroup(await load(b), 'Únos'),
throwsA(isA<NotGroupAdminError>()));
// B si vyrobí operaci s podpisem sebe sama (upravený klient).
await a.service.setGroupAdmin(await load(a), b.fp, true);
final gop = Map<String, dynamic>.from(
sentTo(c, 'ginv').single['gop'] as Map);
outbox.clear();
// Stejné pole, ale autor B, který v C správce není → notAdmin.
await c.service.debugHandleGroupControl(b.fp, {
'v': 1,
't': 'gop',
...gop,
'by': b.fp,
});
final gc = await load(c);
expect(gc.admins, [a.fp]);
expect(gc.rejectedOpAt, isNotNull);
});
test('SPOOF: operaci správce A přeposlanou od B C odmítne', () async {
await a.service.renameGroup(await load(a), 'Nové jméno');
final gmeta = sentTo(c, 'gmeta').single;
outbox.clear();
await c.service.debugHandleGroupControl(b.fp, gmeta);
final gc = await load(c);
expect(gc.epoch, 0);
expect(gc.rejectedOpAt, isNotNull);
});
test('ŠPATNÝ PODPIS: pozměněné jméno se stejným podpisem neprojde',
() async {
await a.service.renameGroup(await load(a), 'Poctivé');
final gmeta = sentTo(c, 'gmeta').single;
outbox.clear();
(gmeta['gop'] as Map)['gname'] = 'Podvržené';
await c.service.debugHandleGroupControl(a.fp, gmeta);
final gc = await load(c);
expect(gc.epoch, 0);
expect(gc.rejectedOpAt, isNotNull);
expect(gc.name, isNot('Podvržené'));
});
test('MEZERA: epocha 2 před 1 se podrží, vyžádá stav a po 1 aplikuje',
() async {
await a.service.renameGroup(await load(a), 'Jedna');
final e1 = sentTo(c, 'gmeta').single;
await a.service.renameGroup(await load(a), 'Dvě');
final e2 = sentTo(c, 'gmeta').last;
outbox.clear();
await c.service.debugHandleGroupControl(a.fp, e2);
var gc = await load(c);
expect(gc.epoch, 0);
expect(gc.needsResync, isTrue);
expect(gc.pendingOps, hasLength(1));
expect(sentTo(a, 'gsync'), hasLength(1),
reason: 'žádost o stav jde správci');
await c.service.debugHandleGroupControl(a.fp, e1);
gc = await load(c);
expect(gc.epoch, 2);
expect(gc.name, 'Dvě');
expect(gc.needsResync, isFalse);
expect(gc.pendingOps, isEmpty);
});
test('ZASTARALÁ: opakované doručení téže operace nic nezmění', () async {
await a.service.renameGroup(await load(a), 'Jednou');
final e1 = sentTo(c, 'gmeta').single;
await deliver([a, b, c]);
final before = jsonEncode((await load(c)).toJson());
await c.service.debugHandleGroupControl(a.fp, e1);
expect(jsonEncode((await load(c)).toJson()), before);
});
test('FORK přes prev: resync stavem od správce a konec bez smyčky',
() async {
// C má kvůli rozdílné historii jiný stav na stejné epoše.
final gc0 = await load(c)..name = 'Rozešlá historie';
await c.storage.saveGroup(gc0);
await a.service.renameGroup(await load(a), 'Pravda');
await deliver([c]);
var gc = await load(c);
expect(gc.needsResync, isTrue);
expect(gc.name, 'Rozešlá historie');
// gsync → A odpoví podepsaným stavem → C ho přijme (epocha 1 > 0).
await deliver([a, c]);
await deliver([a, c]);
gc = await load(c);
expect(gc.name, 'Pravda');
expect(gc.epoch, 1);
expect(gc.needsResync, isFalse);
expect(outbox, isEmpty, reason: 'odpověď už nic dalšího nevyvolá');
});
test('poslední správce se nemůže degradovat; při odchodu předá roli',
() async {
await expectLater(a.service.setGroupAdmin(await load(a), a.fp, false),
throwsA(isA<LastGroupAdminError>()));
await a.service.leaveGroup(await load(a));
await deliver([a, b, c]);
final gb = await load(b);
final naslednik = ([b.fp, c.fp]..sort()).first;
expect(gb.memberIds, isNot(contains(a.fp)));
expect(gb.admins, [naslednik]);
expect((await load(c)).admins, [naslednik]);
expect((await load(a)).left, isTrue);
});
});
group('kompatibilní režim v1 ↔ v2', () {
late _Phone a, b, c, d;
late String gid;
setUp(() async {
outbox.clear();
a = await phone('Alice');
b = await phone('Bob');
c = await phone('Cyril');
d = await phone('Dana');
await introduce([a, b, c, d]);
final contacts = (await a.storage.loadContacts())
.where((x) => x.id != d.fp)
.toList();
gid = (await a.service.createGroup('Chata', contacts)).id;
await deliver([a, b, c]);
});
/// Legacy `ginv` od B (bez `gop`), který přidává Danu — tak přidává v1.
Map<String, dynamic> legacyAddFromB() {
Map<String, dynamic> e(_Phone p) => {
'f': p.fp,
'n': p.name,
'ik': base64Encode(p.identity.publicIdentity.identityKey),
'aid': 'a' * 32,
'da': base64Encode(Uint8List(32)),
};
return {
'v': 1,
't': 'ginv',
'gid': gid,
'gname': 'Chata',
'roster': [e(a), e(b), e(c), e(d)],
};
}
test('v1 člen ve skupině → ginv od ne-správce projde (jen přidání)',
() async {
// C inzeruje v2 (poslal skupinovou zprávu s gv), B je v1 — nic.
final gc = (await c.storage.loadGroup(gid))!..v2Members.add(a.fp);
await c.storage.saveGroup(gc);
await c.service.debugHandleGroupControl(b.fp, legacyAddFromB());
final after = (await c.storage.loadGroup(gid))!;
expect(after.memberIds, contains(d.fp));
expect(after.admins, [a.fp], reason: 'role se legacy cestou nemění');
});
test('všichni inzerují v2 → ginv od ne-správce neprojde', () async {
final gc = (await c.storage.loadGroup(gid))!
..v2Members.addAll([a.fp, b.fp]);
await c.storage.saveGroup(gc);
await c.service.debugHandleGroupControl(b.fp, legacyAddFromB());
final after = (await c.storage.loadGroup(gid))!;
expect(after.memberIds, isNot(contains(d.fp)));
expect(after.rejectedOpAt, isNotNull);
});
test('ve vynucené skupině jde operace samostatně jako t:gop', () async {
final ga = (await a.storage.loadGroup(gid))!
..v2Members.addAll([b.fp, c.fp]);
await a.storage.saveGroup(ga);
await a.service.renameGroup(ga, 'Jen v2');
expect(sentTo(c, 'gop'), hasLength(1));
expect(sentTo(c, 'gmeta'), isEmpty);
await deliver([a, b, c]);
expect((await c.storage.loadGroup(gid))!.name, 'Jen v2');
});
test('ne-správce v kompatibilním režimu přidá legacy cestou, ve '
'vynucené skupině nesmí', () async {
final contactD =
(await b.storage.loadContacts()).firstWhere((x) => x.id == d.fp);
var gb = (await b.storage.loadGroup(gid))!;
await b.service.addGroupMembers(gb, [contactD]);
final inv = sentTo(c, 'ginv').single;
expect(inv.containsKey('gop'), isFalse);
outbox.clear();
gb = (await b.storage.loadGroup(gid))!
..memberIds.remove(d.fp)
..v2Members.addAll([a.fp, c.fp, d.fp]);
await b.storage.saveGroup(gb);
await expectLater(b.service.addGroupMembers(gb, [contactD]),
throwsA(isA<NotGroupAdminError>()));
});
test('capability se učí z gv ve skupinové zprávě a nejde odebrat',
() async {
final ga = (await a.storage.loadGroup(gid))!;
expect(ga.v2Members, isEmpty);
// Legacy gmeta od B s gv:2 (v2 klient) — naučí; bez gv nic neodebere.
await a.service.debugHandleGroupControl(
b.fp, {'v': 1, 't': 'gmeta', 'gid': gid, 'gname': 'X', 'gv': 2});
await a.service.debugHandleGroupControl(
b.fp, {'v': 1, 't': 'gmeta', 'gid': gid, 'gname': 'Y'});
expect((await a.storage.loadGroup(gid))!.v2Members, {b.fp});
});
});
test('migrace uložené v1 skupiny proběhne při načtení bez sítě', () async {
final a = await phone('Alice');
dbs.last.groupPut(
'stara',
jsonEncode({
'id': 'stara',
'name': 'Z verze 1.1',
'memberIds': [a.fp, 'fpB'],
'createdBy': a.fp,
'createdAt': '2026-07-10T10:00:00.000',
'left': false,
}),
);
final g = (await a.storage.loadGroup('stara'))!;
expect(g.admins, [a.fp]);
expect(g.epoch, 0);
expect(outbox, isEmpty);
});
}
class _Phone {
_Phone(this.name, this.identity, this.storage, this.service);
final String name;
final LocalIdentity identity;
final SecureStorageService storage;
final MessagingService service;
String get fp => identity.fingerprint.display;
}