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>
This commit is contained in:
parent
fad12b4411
commit
5b9df73756
2 changed files with 981 additions and 3339 deletions
|
|
@ -1,315 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
void main() {
|
||||
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
|
||||
|
||||
late FakeAuthGateway gateway;
|
||||
|
||||
/// Whether this device holds content a switch would leave behind.
|
||||
late bool holdsContent;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway(status: anonymous);
|
||||
holdsContent = false;
|
||||
});
|
||||
|
||||
/// A bloc as the account dialog starts it, once it knows whether this
|
||||
/// instance offers GitHub sign-in.
|
||||
Future<GitHubSignInBloc> ready() async {
|
||||
final bloc = GitHubSignInBloc(
|
||||
AuthRepository(gateway, MemorySignInAddresses()),
|
||||
deviceHoldsContent: () async => holdsContent,
|
||||
)..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
return bloc;
|
||||
}
|
||||
|
||||
test('offers nothing where the instance has no GitHub sign-in', () async {
|
||||
gateway.offersGitHub = false;
|
||||
final bloc = GitHubSignInBloc(
|
||||
AuthRepository(gateway, MemorySignInAddresses()),
|
||||
deviceHoldsContent: () async => holdsContent,
|
||||
)..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateUnavailable()));
|
||||
});
|
||||
|
||||
test('offers linking no GitHub account where the instance signs people in '
|
||||
'with a password (ADR 0017)', () async {
|
||||
gateway.linksGitHub = false;
|
||||
final bloc = GitHubSignInBloc(
|
||||
AuthRepository(gateway, MemorySignInAddresses()),
|
||||
deviceHoldsContent: () async => holdsContent,
|
||||
)..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const GitHubSignInStateReady(linking: false)),
|
||||
);
|
||||
});
|
||||
|
||||
test('links the GitHub account to the person', () async {
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateLinked(),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
|
||||
);
|
||||
});
|
||||
|
||||
test('changes nothing when the person cancels on GitHub', () async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.cancelled;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateReady(),
|
||||
]),
|
||||
);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
|
||||
test(
|
||||
"the person can cancel while GitHub's page is open; nothing changes",
|
||||
() async {
|
||||
gateway
|
||||
..canCancelGitHub = true
|
||||
..gitHubPageOpens = true;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const GitHubSignInStateBusy(cancellable: true)),
|
||||
);
|
||||
bloc.add(const GitHubSignInEventCancelled());
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
expect(gateway.gitHubCancels, 1);
|
||||
expect(gateway.status, anonymous);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"offers no cancel where the system's sign-in screen has its own",
|
||||
() async {
|
||||
gateway.gitHubPageOpens = true;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(
|
||||
isA<GitHubSignInStateBusy>().having(
|
||||
(state) => state.cancellable,
|
||||
'cancellable',
|
||||
isFalse,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"closing the dialog while GitHub's page is open cancels the sign-in",
|
||||
() async {
|
||||
gateway
|
||||
..canCancelGitHub = true
|
||||
..gitHubPageOpens = true;
|
||||
final bloc = await ready();
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const GitHubSignInStateBusy(cancellable: true)),
|
||||
);
|
||||
|
||||
await bloc.close();
|
||||
|
||||
expect(gateway.gitHubCancels, 1);
|
||||
expect(gateway.status, anonymous);
|
||||
},
|
||||
);
|
||||
|
||||
test('closing the dialog otherwise cancels nothing', () async {
|
||||
final bloc = await ready();
|
||||
|
||||
await bloc.close();
|
||||
|
||||
expect(gateway.gitHubCancels, 0);
|
||||
});
|
||||
|
||||
test("switches right away when the GitHub account is someone else's and "
|
||||
'this device holds nothing', () async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateBusy()));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(gateway.gitHubSwitches, 1);
|
||||
});
|
||||
|
||||
test("someone identified whose GitHub account is another person's is told "
|
||||
'so, never switched: their own account stays theirs', () async {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
)..gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateTaken(),
|
||||
]),
|
||||
);
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
);
|
||||
});
|
||||
|
||||
group("when the GitHub account is someone else's and this device holds "
|
||||
'content', () {
|
||||
late GitHubSignInBloc bloc;
|
||||
|
||||
setUp(() async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
holdsContent = true;
|
||||
bloc = (await ready())..add(const GitHubSignInEventRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateConfirmSwitch(),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('asks first; staying anonymous changes nothing', () async {
|
||||
bloc.add(const GitHubSignInEventSwitchDeclined());
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
|
||||
test('switches once confirmed', () async {
|
||||
bloc.add(const GitHubSignInEventSwitchConfirmed());
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateBusy()));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(gateway.gitHubSwitches, 1);
|
||||
});
|
||||
|
||||
test("cancelling GitHub's page for the switch stays anonymous", () async {
|
||||
gateway
|
||||
..canCancelGitHub = true
|
||||
..gitHubPageOpens = true;
|
||||
|
||||
bloc.add(const GitHubSignInEventSwitchConfirmed());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const GitHubSignInStateBusy(cancellable: true)),
|
||||
);
|
||||
bloc.add(const GitHubSignInEventCancelled());
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
});
|
||||
|
||||
test('says why the instance refused, keeping whether it links', () async {
|
||||
gateway.linksGitHub = false;
|
||||
final bloc = GitHubSignInBloc(
|
||||
AuthRepository(gateway, MemorySignInAddresses()),
|
||||
deviceHoldsContent: () async => holdsContent,
|
||||
)..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const GitHubSignInStateReady(linking: false)),
|
||||
);
|
||||
gateway.gitHubError = const GitHubSignInFailed(
|
||||
GitHubSignInProblem.deprecated,
|
||||
);
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateReady(
|
||||
linking: false,
|
||||
failed: true,
|
||||
problem: GitHubSignInProblem.deprecated,
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('an anonymous person whose GitHub account now signs in with its '
|
||||
'password is told so, and stays', () async {
|
||||
gateway
|
||||
..gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson
|
||||
..gitHubSwitchError = const GitHubSignInFailed(
|
||||
GitHubSignInProblem.usePassword,
|
||||
);
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateReady(
|
||||
failed: true,
|
||||
problem: GitHubSignInProblem.usePassword,
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
|
||||
test('says so when signing in fails', () async {
|
||||
final bloc = await ready();
|
||||
gateway.gitHubError = Exception('No connection');
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateReady(failed: true),
|
||||
]),
|
||||
);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
}
|
||||
|
|
@ -4,65 +4,80 @@ import 'package:halkyon_app/testing.dart';
|
|||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late MemorySignedOutMark mark;
|
||||
late SessionBloc bloc;
|
||||
|
||||
SessionStateActive anonymous(int number) => SessionStateActive(
|
||||
SignedIn(authUserId: 'anonymous-$number', isIdentified: false),
|
||||
);
|
||||
/// 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(), bool signedOut = false}) {
|
||||
void start({AuthStatus status = const SignedOut()}) {
|
||||
gateway = FakeAuthGateway(status: status);
|
||||
mark = MemorySignedOutMark(signedOut);
|
||||
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark);
|
||||
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()));
|
||||
}
|
||||
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
test('signs in anonymously at a first start, nobody having signed out '
|
||||
'here', () async {
|
||||
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,
|
||||
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
|
||||
);
|
||||
expect(gateway.signIns, 1);
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
expect(gateway.accountDeletions, 0);
|
||||
expect(gateway.signOuts, 0);
|
||||
});
|
||||
|
||||
test('keeps a stored session instead of creating a second account', () async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
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)));
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
test('reports a failed sign-in and signs in when asked again', () async {
|
||||
start();
|
||||
gateway.signInError = Exception('No connection');
|
||||
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,
|
||||
emitsInOrder([const SessionStateLoading(), const SessionStateFailed()]),
|
||||
);
|
||||
|
||||
gateway.signInError = null;
|
||||
bloc.add(const SessionEventSignInRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionStateLoading(), anonymous(2)]),
|
||||
);
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
expect(gateway.signOuts, 1);
|
||||
expect(gateway.status, const SignedOut());
|
||||
});
|
||||
|
||||
test('after signing out, asks how to start, making no account; the next '
|
||||
'start of the app asks again', () async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
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)));
|
||||
|
|
@ -78,36 +93,11 @@ void main() {
|
|||
const SessionStateSignedOut(),
|
||||
]),
|
||||
);
|
||||
// Signing out of an anonymous account deletes it.
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.signIns, 0);
|
||||
expect(mark.signedOut, isTrue);
|
||||
|
||||
await bloc.close();
|
||||
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark)
|
||||
..add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
expect(gateway.signIns, 0);
|
||||
expect(gateway.accountDeletions, 0);
|
||||
});
|
||||
|
||||
test('starting without an account from there signs in anonymously, and '
|
||||
'the device is no longer signed out', () async {
|
||||
start(signedOut: true);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
bloc.add(const SessionEventStartedAnonymously());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
|
||||
);
|
||||
expect(mark.signedOut, isFalse);
|
||||
});
|
||||
|
||||
test('signing in with GitHub from there switches to its person; '
|
||||
'cancelled, it asks again', () async {
|
||||
start(signedOut: true);
|
||||
test('signing in with GitHub from there switches to its person', () async {
|
||||
start();
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
|
|
@ -121,13 +111,11 @@ void main() {
|
|||
);
|
||||
bloc.add(const SessionEventPasswordOfferDone());
|
||||
await expectLater(bloc.stream, emits(const SessionStateActive(person)));
|
||||
expect(gateway.signIns, 0);
|
||||
expect(mark.signedOut, isFalse);
|
||||
});
|
||||
|
||||
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(signedOut: true);
|
||||
start();
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
|
|
@ -152,13 +140,12 @@ void main() {
|
|||
const SessionStateSignedOut(problem: SignedOutProblem.failed),
|
||||
]),
|
||||
);
|
||||
expect(gateway.signIns, 0);
|
||||
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(signedOut: true);
|
||||
start();
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
|
|
@ -183,7 +170,6 @@ void main() {
|
|||
const SessionStateSignedOut(problem: SignedOutProblem.gitHubEnded),
|
||||
]),
|
||||
);
|
||||
expect(gateway.signIns, 0);
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
});
|
||||
|
||||
|
|
@ -196,7 +182,6 @@ void main() {
|
|||
gateway.revokeSession();
|
||||
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
test('deleting the account asks how to start', () async {
|
||||
|
|
@ -215,7 +200,6 @@ void main() {
|
|||
]),
|
||||
);
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
test('a deletion that fails keeps the person signed in, their app in '
|
||||
|
|
@ -236,7 +220,6 @@ void main() {
|
|||
]),
|
||||
);
|
||||
expect(gateway.status, stored);
|
||||
expect(mark.signedOut, isFalse);
|
||||
|
||||
gateway.deletionError = null;
|
||||
bloc.add(const SessionEventDeleteRequested());
|
||||
|
|
@ -269,6 +252,5 @@ void main() {
|
|||
]),
|
||||
);
|
||||
expect(gateway.status, stored);
|
||||
expect(mark.signedOut, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue