fix(auth): a member deletes nobody while the register is out of reach

A person still anonymous on a member was deleted there on the session
alone while the register was out of reach (the reading of the fixes' B1).
But the register may have linked them while their becoming identified on
the member failed, so with both failing, an account of the register's went
without its proof (the short reading's L2). A member now refuses every
deletion while it can't ask the register (decided by the developer on
21 Oct).

In the app, signing out of an anonymous account, which deletes it, signs
the device out all the same when the deletion fails, as a switch already
leaves one behind: the account is left to the instance's pruning of
anonymous accounts unused for 90 days. signInWays gets its doc comment
back.

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 17:49:27 +02:00
commit 634f2756d9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 73 additions and 35 deletions

View file

@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/data/sign_in_addresses.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
@ -32,11 +34,26 @@ class AuthRepository(
/// Signs out on this device. An anonymous account can never be signed into
/// again, so signing out of one deletes it with its progress (decided
/// 2026-09-30); the UI warns first.
Future<void> signOut() => switch (_gateway.status) {
SignedIn(isIdentified: false) => _gateway.deleteAccount(),
_ => _gateway.signOut(),
};
/// 2026-09-30); the UI warns first. Should deleting it fail (on a member
/// while the instance's register is out of reach, which deletes nobody
/// then: the short reading's L2), the device signs out all the same, as a
/// switch leaves one behind: the account is left to the instance's
/// pruning of anonymous accounts unused.
Future<void> signOut() async {
if (_gateway.status case SignedIn(isIdentified: false)) {
try {
await _gateway.deleteAccount();
return;
} on Exception catch (error, st) {
log(
'Deleting the anonymous account failed: ${error.runtimeType}',
name: 'Auth',
stackTrace: st,
);
}
}
await _gateway.signOut();
}
/// Deletes the signed-in person with everything they have, on the server
/// and all their devices; the UI asks first.

View file

@ -27,6 +27,10 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
/// How often the signed-in person's account was deleted.
int accountDeletions = 0;
/// Thrown by the next deletions of the account while set, which then
/// delete nothing.
Exception? deletionError;
/// Whether the instance offers GitHub sign-in.
bool offersGitHub = true;
@ -79,6 +83,9 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
@override
Future<void> deleteAccount() async {
accountDeletions++;
if (deletionError case final error?) {
throw error;
}
_set(const SignedOut());
}

View file

@ -46,6 +46,26 @@ void main() {
},
);
test(
'gives an anonymous account up all the same when deleting it fails, '
'as while the register is out of reach: the device signs out, the '
'account left to the pruning (the short reading, L2)',
() async {
final gateway = FakeAuthGateway(
status: const SignedIn(
authUserId: 'anonymous-1',
isIdentified: false,
),
)..deletionError = Exception('Internal server error');
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
expect(gateway.accountDeletions, 1);
expect(gateway.signOuts, 1);
expect(gateway.status, const SignedOut());
},
);
test('only signs an identified person out on this device', () async {
final gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),

View file

@ -54,37 +54,24 @@ class AccountEndpoint extends Endpoint {
);
}
/// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous).
/// Whether [person], on a member, is an account of the register's,
/// whatever this session's scopes say (the full reading's L7: a person
/// the register linked whose becoming identified here failed was deleted
/// here on the session alone): the register decides, with the proof it
/// takes. While the register is out of reach, a person still anonymous
/// here (becoming identified ends that) is none of its, and is deleted
/// here; anyone else waits for it ([RegisterUnavailableException]).
/// takes. While the register is out of reach, nobody is deleted here
/// ([RegisterUnavailableException]), a person still anonymous here
/// neither (the short reading's L2): the register may have linked them
/// while their becoming identified here failed. An anonymous account
/// given up meanwhile stays to the pruning (90 days unused).
static Future<bool> _registersAccount(
Session session,
UuidValue person,
) async {
try {
return (await AccountRegister.of(
session.serverpod,
).knownAccounts(session, {person})).contains(person);
} on RegisterUnavailableException {
// SELECT count(*) FROM "serverpod_auth_idp_anonymous_account"
// WHERE "authUserId" = $1
if (await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0) {
return false;
}
rethrow;
}
}
) async => (await AccountRegister.of(
session.serverpod,
).knownAccounts(session, {person})).contains(person);
/// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous).
Future<SignInWays> signInWays(Session session) => AccountRegister.of(
session.serverpod,
).signInWaysOf(session, session.authenticated!.authUserId);

View file

@ -1085,15 +1085,22 @@ void main() {
);
});
test('of a person anonymous here deletes it here while the register '
'is out of reach: becoming identified ends being anonymous, so '
"none is the register's", () async {
test('of a person anonymous here is refused too while the register '
'is out of reach, deleting nothing here: the register may have '
'linked her while her becoming identified here failed (the '
'short reading, L2)', () async {
register.failing = RegisterUnavailableException('test');
await endpoints.account.delete(asAnna());
expect(await AuthUser.db.findById(session, anna), isNull);
expect(hostDeleted, [anna]);
await expectLater(
endpoints.account.delete(asAnna()),
throwsA(isA<RegisterUnavailableException>()),
);
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNotNull,
);
expect(hostDeleted, isEmpty);
});
});