halkyon/packages/halkyon_base_server/test/integration/auth/email_test.dart
Tizian 「ティツィアーン」 4e33133733
feat(base,app)!: GitHub signs nobody in any more (ADR 0020, D36)
Every account signs in with SimpleLogin or a password now, so GitHub
goes from the module, the shared app package, Learn and Todo, ahead of
ADR 0017's GITHUB_SIGN_IN_ENDS.

- halkyon_base: no `gitHubSignIn` endpoint, GitHub identity provider or
  GitHub refusals; no `githubClientId`/`githubClientSecret`, no
  `GITHUB_SIGN_IN_ENDS` and no `forgetGitHubAccounts`; the register
  loses `account-with-github`. `SignInWays.gitHub`, the basics'
  `gitHubClientId` and `gitHubSignInEnds`, and `PasswordRefusal`'s
  `signInAgain` and `noAddress` go. A first password takes a code
  mailed to the person's own address; an account without one sets none
  (`wrongCredentials`).
- Learn's server: `instance.info()` without `gitHubClientId`, the
  pruning without GitHub, and `minimumAppBuild` 24, as builds up to 23
  (v0.20.0) require `SignInWays.gitHub`: the server deploys with the
  release of build 24.
- halkyon_app: no GitHub button or problems on the start screen, no
  password offered after a GitHub sign-in, and `ServerpodAuthGateway`
  without the browser half only GitHub used (`(client, base:)`). The
  account section offers a first password only to an account with an
  address. `LoopbackWebAuth` and the apps' schemes stay, SimpleLogin's
  ways back.
- Learn's Profile lists no GitHub; the texts go (English, German).
- Serverpod's `serverpod_auth_idp_github_account` stays, as its
  migrations make it; a row goes with its account, and ADR 0020's D36
  gives operators the SQL to delete those left.
- deploy/: compose.yaml and .env.example without GitHub. The READMEs,
  AGENTS.md files and ADR 0017 amended.

BREAKING CHANGE: builds up to 23 can't read the instance's answers
about an account's ways in; minimumAppBuild is 24.

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

478 lines
15 KiB
Dart

import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
/// Keeps every code mailed, in place of a mail service.
final class _Mailbox implements CodeMailer {
final sent = <({String to, String code, String language})>[];
@override
Future<void> send(
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async {
sent.add((to: to, code: code, language: language));
}
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async {}
@override
Future<void> sendCodesWaitNotice(
Session session, {
required String to,
required DateTime until,
required String language,
}) async {}
/// The last code mailed to [address].
String codeFor(String address) =>
sent.lastWhere((mail) => mail.to == address).code;
}
/// A mail service that takes no mail.
final class _Refusing implements CodeMailer {
@override
Future<void> send(
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async => throw const SocketException('Connection refused');
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async => throw const SocketException('Connection refused');
@override
Future<void> sendCodesWaitNotice(
Session session, {
required String to,
required DateTime until,
required String language,
}) async => throw const SocketException('Connection refused');
}
void main() {
withServerpod(
'Codes by mail',
// Mailing and accepting codes count attempts in transactions of their
// own (Serverpod's rate limiter), which the test's database rollback
// can't wrap. So these tests delete what they make instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late _Mailbox mailbox;
/// Signed in on her phone without an address: an anonymous account
/// left from before (ADR 0020, D25), asking for codes as anyone may.
late UuidValue anna;
late UuidValue annasPhone;
/// Identified, signing in with ben@example.org.
late UuidValue ben;
setUp(() async {
useBase(sessionBuilder);
session = sessionBuilder.build();
await _deleteAll(session);
mailbox = _Mailbox();
EmailCodes.of(session.serverpod).mailer = mailbox;
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(session.serverpod, 'ben@example.org'),
createdAt: clock.now(),
),
);
await signInNewDevice(
session,
ben,
scopes: {identified},
);
});
tearDown(() async {
EmailCodes.of(session.serverpod).mailer = null;
await _deleteAll(sessionBuilder.build());
});
TestSessionBuilder signedIn(
UuidValue person,
UuidValue sessionId, {
bool isIdentified = false,
}) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{if (isIdentified) identified},
authId: sessionId.uuid,
),
);
TestSessionBuilder asAnna() => signedIn(anna, annasPhone);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
/// Mails a code to [address] for [as] and types it.
group('refuses', () {
test('nothing that turns a code into a session: a code signs nobody '
"in, and makes no address anyone's", () async {
final request = await endpoints.emailCode.sendCode(
notSignedIn(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
expect(
await proveAddressCode(
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
purpose: EmailCodePurpose.signUp,
),
addressHash(session.serverpod, 'anna@example.org'),
);
expect(await _addressesOf(session, anna), isEmpty);
expect(await ServerSideSession.db.count(session), 2);
expect(await EmailCodeRequest.db.count(session), 0);
});
test(
'everything on an instance that sends no mail, and says so',
() async {
EmailCodes.of(session.serverpod).mailer = null;
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.unavailable)),
);
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
isFalse,
);
},
);
test('a code it could not mail, keeping nothing of it', () async {
EmailCodes.of(session.serverpod).mailer = _Refusing();
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.mailFailed)),
);
expect(await EmailCodeRequest.db.count(session), 0);
expect(await SecretChallenge.db.count(session), 0);
});
test('what is no address', () async {
for (final typed in ['anna', 'anna@', '@example.org', 'a b@c.de']) {
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: typed,
language: 'en',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.notAnAddress)),
reason: typed,
);
}
expect(mailbox.sent, isEmpty);
});
test('a wrong code, and after three wrong ones even the right one; '
'nothing changes', () async {
final request = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
for (var i = 0; i < 3; i++) {
await expectLater(
proveAddressCode(
session,
request: request,
code: 'AAAA-AAAA',
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.wrongCode)),
);
}
await expectLater(
proveAddressCode(
session,
request: request,
code: mailbox.codeFor('anna@example.org'),
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.tooManyTries)),
);
expect(await _addressesOf(session, anna), isEmpty);
});
test('a code past its 15 minutes, a code used, and the oldest of '
'four: up to three are valid at once, so codes others ask for '
"don't void the one the owner is typing", () async {
final late = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
final lateCode = mailbox.codeFor('anna@example.org');
await withClock(
Clock.fixed(clock.now().add(const Duration(minutes: 16))),
() => expectLater(
proveAddressCode(
session,
request: late,
code: lateCode,
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
),
);
Future<(UuidValue, String)> ask(String network) async {
final request = await sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: network,
purpose: EmailCodePurpose.signUp,
);
return (request, mailbox.codeFor('anna@example.org'));
}
final (owners, ownersCode) = await ask('203.0.113.1');
final (second, secondCode) = await ask('198.51.100.7');
final (third, thirdCode) = await ask('198.51.100.7');
// Three valid: the owner's still is.
await proveAddressCode(
session,
request: owners,
code: ownersCode,
purpose: EmailCodePurpose.signUp,
);
final (fourth, fourthCode) = await ask('198.51.100.7');
final (fifth, _) = await ask('192.0.2.9');
// Five asked for, three valid: the two oldest left went.
await expectLater(
proveAddressCode(
session,
request: second,
code: secondCode,
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
await proveAddressCode(
session,
request: third,
code: thirdCode,
purpose: EmailCodePurpose.signUp,
);
await proveAddressCode(
session,
request: fourth,
code: fourthCode,
purpose: EmailCodePurpose.signUp,
);
await expectLater(
proveAddressCode(
session,
request: fourth,
code: fourthCode,
purpose: EmailCodePurpose.signUp,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
expect(fifth, isNotNull);
});
test('a fourth code for one address from one network within an '
'hour, an eleventh for one address from any, and an eleventh '
'from one network', () async {
Future<void> ask(String address, String network) => sendSignInCode(
session,
address: address,
language: 'en',
network: network,
purpose: EmailCodePurpose.signUp,
);
for (var i = 0; i < 3; i++) {
await ask('anna@example.org', '203.0.113.1');
}
await expectLater(
ask('Anna@Example.org', '203.0.113.1'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
// Another network still gets one: whoever filled the first count
// holds back no other, the owner's among them.
for (var i = 0; i < 7; i++) {
await ask('anna@example.org', '198.51.100.${i ~/ 3}');
}
// Ten mailed to one address in the hour: no more, from anywhere.
await expectLater(
ask('anna@example.org', '192.0.2.200'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
for (var i = 0; i < 10; i++) {
await ask('anna$i@example.org', '192.0.2.9');
}
await expectLater(
ask('anna.else@example.org', '192.0.2.9'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
expect(mailbox.sent, hasLength(20));
});
test('saying how someone signs in when not signed in', () async {
await expectLater(
endpoints.account.signInWays(notSignedIn()),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
});
test('mails a code in the language asked for, the same whether anybody '
'has the address or not', () async {
final known = await endpoints.emailCode.sendCode(
asAnna(),
address: 'ben@example.org',
language: 'de',
purpose: EmailCodePurpose.signUp,
);
final unknown = await endpoints.emailCode.sendCode(
asAnna(),
address: ' Nobody@Example.org ',
language: 'fr',
purpose: EmailCodePurpose.signUp,
);
expect(known, isNot(unknown));
expect(
[for (final mail in mailbox.sent) (mail.to, mail.language)],
[('ben@example.org', 'de'), ('nobody@example.org', 'en')],
);
expect(
mailbox.sent.map((mail) => mail.code),
everyElement(matches(RegExp(r'^[0-9A-Z]{4}-[0-9A-Z]{4}$'))),
);
});
test('tells the app that this instance offers it', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
true,
);
});
test('deleting a person deletes their address', () async {
await deletePerson(session, ben);
expect(await EmailIdentity.db.count(session), 0);
});
test('forgets the codes past their time, hourly', () async {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
final now = clock.now();
expect(await forgetExpiredEmailCodes(session, now: now), 0);
expect(
await forgetExpiredEmailCodes(
session,
now: now.add(const Duration(minutes: 16)),
),
1,
);
expect(await EmailCodeRequest.db.count(session), 0);
expect(await SecretChallenge.db.count(session), 0);
});
},
);
}
Matcher _refusal(EmailSignInRefusal reason) =>
isA<EmailSignInRefusedException>().having(
(exception) => exception.reason,
'reason',
reason,
);
/// The hashes of the addresses [person] signs in with.
Future<List<String>> _addressesOf(Session session, UuidValue person) async => [
for (final identity in await EmailIdentity.db.find(
session,
where: (t) => t.authUserId.equals(person),
))
identity.addressHash,
];
/// Deletes every rate-limit record, code, address and person; their
/// sessions and other identities go with them (`ON DELETE CASCADE`).
Future<void> _deleteAll(Session session) async {
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailCodeRequest.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await SecretChallenge.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}