fix(auth): members learn of ended sessions in the background

The full reading's L5: every change or reset of a password told the
members inside the request, up to 500 notices one after another with a
timeout of 10 seconds each, so a member out of reach held the change up
for minutes. Now announceSessionsEnded tells the members of this account's
notices alone, in the background on a session of its own; those not
confirmed wait for the hourly run as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 13:28:03 +02:00
commit 76291e6ab4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 74 additions and 8 deletions

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:clock/clock.dart';
@ -7,6 +8,8 @@ import 'package:halkyon_base_server/src/password/password_endpoint.dart'
show changesBegunPerSession;
import 'package:halkyon_base_server/src/password/passwords.dart'
show writeNewPassword;
import 'package:halkyon_base_server/src/register/sessions_ended.dart'
show sessionNoticesDelivered;
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
@ -24,6 +27,10 @@ final class _Members implements MemberNotifier {
final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[];
bool answering = true;
/// What a member's answer waits for, as one out of reach keeps it
/// waiting; none by default.
Future<void>? answerAfter;
@override
Future<bool> accountGone(Uri member, UuidValue account) async => answering;
@ -34,6 +41,7 @@ final class _Members implements MemberNotifier {
required DateTime endedBefore,
UuidValue? keepSession,
}) async {
await answerAfter;
sessionsTold.add((member, account, endedBefore, keepSession));
return answering;
}
@ -241,6 +249,7 @@ void main() {
final sessions = await sessionsOf(anna);
expect(sessions, contains(annasPhone));
expect(sessions, isNot(contains(annasLaptop)));
await sessionNoticesDelivered();
final (member, account, endedBefore, kept) =
members.sessionsTold.first;
expect(member, _todo);
@ -307,6 +316,7 @@ void main() {
kept,
);
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
await sessionNoticesDelivered();
expect(members.sessionsTold, isEmpty);
});
@ -566,10 +576,23 @@ void main() {
);
});
test('is done without waiting for the members: one out of reach '
"holds no change up (the full reading's L5)", () async {
final reached = Completer<void>();
members.answerAfter = reached.future;
await change();
expect(members.sessionsTold, isEmpty);
reached.complete();
await sessionNoticesDelivered();
expect(members.sessionsTold, hasLength(1));
expect(await SessionsEndedNotice.db.count(session), 0);
});
test('tells a member that missed it again, hourly, until it '
'confirms', () async {
members.answering = false;
await change();
await sessionNoticesDelivered();
expect(await SessionsEndedNotice.db.count(session), 1);
members.answering = true;
@ -1042,6 +1065,7 @@ void main() {
expect(sessions, hasLength(1));
expect(sessions, isNot(contains(annasPhone)));
expect(sessions, isNot(contains(annasLaptop)));
await sessionNoticesDelivered();
expect(members.sessionsTold.single.$2, anna);
expect(members.sessionsTold.single.$4, isNull);
});

View file

@ -7,6 +7,8 @@ import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:halkyon_base_server/src/register/instance_api.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart'
show sessionNoticesDelivered;
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:serverpod/serverpod.dart';
@ -677,6 +679,7 @@ void main() {
(account: annaOnTodo, endedBefore: now),
);
// Every member is told to end the account's sessions too.
await sessionNoticesDelivered();
expect(members.sessionsTold.single, (_todo, annaOnTodo, now, null));
final forReset = await remote.beginPasswordSignIn(
@ -809,6 +812,7 @@ void main() {
password: device.newPassword(),
keepSession: annasPhone,
);
await sessionNoticesDelivered();
expect(members.sessionsTold.single, (
_todo,
annaOnTodo,