halkyon/packages/halkyon_app/test/auth/bloc/session_bloc_test.dart
Tizian 「ティツィアーン」 5b9df73756
feat(app)!: no anonymous accounts on the device (ADR 0020, D4, D25)
Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.

What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.

The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 16:14:37 +02:00

256 lines
8.4 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
void main() {
late FakeAuthGateway gateway;
late SessionBloc bloc;
/// An anonymous account's session, left from before they went (ADR 0020,
/// D25).
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
void start({AuthStatus status = const SignedOut()}) {
gateway = FakeAuthGateway(status: status);
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()));
}
tearDown(() => bloc.close());
test('nobody signed in, it asks how to start, making no account (ADR '
'0020, D4)', () async {
start();
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
expect(gateway.accountDeletions, 0);
expect(gateway.signOuts, 0);
});
test('keeps a stored session', () async {
const stored = SignedIn(authUserId: 'stored', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
});
test(
"an anonymous account's session left from before ends as the app "
'starts: the account deleted, the device asked how to start (D25)',
() async {
start(status: anonymous);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
expect(gateway.accountDeletions, 1);
expect(gateway.status, const SignedOut());
},
);
test('the instance out of reach, the anonymous session still ends on the '
"device: the account is left to the instance's pruning", () async {
start(status: anonymous);
gateway.deletionError = Exception('No connection');
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
expect(gateway.signOuts, 1);
expect(gateway.status, const SignedOut());
});
test("an anonymous session the device can't even end never opens its "
'app: the start screen all the same', () async {
start(status: anonymous);
gateway
..deletionError = Exception('No connection')
..signOutError = Exception('The keyring is locked');
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
expect(gateway.status, anonymous);
});
test('after signing out, asks how to start, deleting nothing', () async {
const stored = SignedIn(authUserId: 'stored', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
bloc.add(const SessionEventSignOutRequested());
// The person's app stays while signing out, never the launch screen in
// between.
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateActive(stored, busy: SessionAction.signOut),
const SessionStateSignedOut(),
]),
);
expect(gateway.accountDeletions, 0);
});
test('signing in with GitHub from there switches to its person', () async {
start();
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
bloc.add(const SessionEventGitHubRequested());
// A password is offered first (ADR 0017, D2), then the person's app.
const person = SignedIn(authUserId: 'github-person', isIdentified: true);
await expectLater(
bloc.stream,
emitsThrough(const SessionStateGitHubSignedIn(person)),
);
bloc.add(const SessionEventPasswordOfferDone());
await expectLater(bloc.stream, emits(const SessionStateActive(person)));
});
test('a GitHub account nobody here signs in with yet is said so; any '
'other refusal fails, and nothing is made either way', () async {
start();
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.unknownAccount,
);
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateSignedOut(busy: true),
const SessionStateSignedOut(problem: SignedOutProblem.noGitHubAccount),
]),
);
gateway.gitHubError = const GitHubSignInFailed(GitHubSignInProblem.refused);
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateSignedOut(busy: true),
const SessionStateSignedOut(problem: SignedOutProblem.failed),
]),
);
expect(gateway.gitHubSwitches, 0);
});
test('an account that signs in with its password now, and GitHub having '
'ended, are each said so, signing nobody in', () async {
start();
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.usePassword,
);
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateSignedOut(busy: true),
const SessionStateSignedOut(problem: SignedOutProblem.usePassword),
]),
);
gateway.gitHubError = const GitHubSignInFailed(GitHubSignInProblem.ended);
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateSignedOut(busy: true),
const SessionStateSignedOut(problem: SignedOutProblem.gitHubEnded),
]),
);
expect(gateway.gitHubSwitches, 0);
});
test('a session the server ended asks how to start too', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
gateway.revokeSession();
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
});
test('deleting the account asks how to start', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
bloc.add(const SessionEventDeleteRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateActive(stored, busy: SessionAction.delete),
const SessionStateSignedOut(),
]),
);
expect(gateway.accountDeletions, 1);
});
test('a deletion that fails keeps the person signed in, their app in '
'place, and says so; asked again, it tries again', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
gateway.deletionError = Exception('No connection');
bloc.add(const SessionEventDeleteRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateActive(stored, busy: SessionAction.delete),
const SessionStateActive(stored, problem: SessionProblem.deleteFailed),
]),
);
expect(gateway.status, stored);
gateway.deletionError = null;
bloc.add(const SessionEventDeleteRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateActive(stored, busy: SessionAction.delete),
const SessionStateSignedOut(),
]),
);
expect(gateway.accountDeletions, 2);
});
test('a sign-out that fails keeps the person signed in, their app in '
'place, and says so', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
gateway.signOutError = Exception('The keyring is locked');
bloc.add(const SessionEventSignOutRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateActive(stored, busy: SessionAction.signOut),
const SessionStateActive(stored, problem: SessionProblem.signOutFailed),
]),
);
expect(gateway.status, stored);
});
}