halkyon/packages/halkyon_base_server/test/unit/code_mail_test.dart

265 lines
8.1 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_base_server/base.dart';
import 'package:mailer/mailer.dart' as mailer;
import 'package:mailer/smtp_server.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
/// An SMTP server on a loopback port that takes one mail at a time, offers
/// no TLS, and keeps what it is told.
final class _FakeSmtp {
_FakeSmtp._(this._socket) {
_socket.listen(_serve);
}
static Future<_FakeSmtp> start() async =>
_FakeSmtp._(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0));
final ServerSocket _socket;
/// The commands received, in order.
final commands = <String>[];
/// What each mail's DATA held, its lines joined.
final mails = <String>[];
int get port => _socket.port;
Future<void> close() => _socket.close();
void _serve(Socket client) {
client.write('220 fake ESMTP\r\n');
var step = '';
final data = StringBuffer();
utf8.decoder.bind(client).transform(const LineSplitter()).listen((line) {
if (step == 'data') {
if (line == '.') {
step = '';
mails.add(data.toString());
data.clear();
client.write('250 2.0.0 queued\r\n');
} else {
data.writeln(line);
}
return;
}
if (step == 'auth') {
step = '';
client.write('235 2.7.0 Authentication successful\r\n');
return;
}
commands.add(line);
final command = line.toUpperCase();
if (command.startsWith('EHLO')) {
client.write('250-fake\r\n250 AUTH PLAIN\r\n');
} else if (command == 'AUTH PLAIN') {
step = 'auth';
client.write('334 \r\n');
} else if (command == 'DATA') {
step = 'data';
client.write('354 End data with <CR><LF>.<CR><LF>\r\n');
} else if (command == 'QUIT') {
client
..write('221 2.0.0 Bye\r\n')
..close();
} else {
client.write('250 2.0.0 OK\r\n');
}
});
}
}
/// The text of [mail] as DATA held it: its headers and its body, decoded
/// from base64 where it was encoded so.
({String headers, String body}) _read(String mail) {
final at = mail.indexOf('\n\n');
final headers = mail.substring(0, at);
final body = mail.substring(at + 2);
return (
headers: headers,
body: headers.toLowerCase().contains('content-transfer-encoding: base64')
? utf8.decode(base64.decode(body.replaceAll(RegExp(r'\s'), '')))
: body,
);
}
void main() {
group('the mail', () {
test('is plain text with the code first, in the subject too, naming '
'the instance and how long the code lasts', () {
final mail = codeMail(
code: 'K7QX-M2PD',
language: 'en',
app: 'Halkyon Learn',
instance: 'learn.halkyon.eu',
);
expect(mail.subject, 'K7QX-M2PD is your Halkyon Learn code');
expect(mail.body, startsWith('K7QX-M2PD\n\n'));
expect(mail.body, contains('sign in on learn.halkyon.eu'));
expect(mail.body, contains('15 minutes'));
expect(mail.body, isNot(contains('http')));
});
test('in German for German, in English for anything else', () {
({String subject, String body}) mailIn(String language) => codeMail(
code: 'K7QX-M2PD',
language: language,
app: 'Halkyon Learn',
instance: 'x',
);
expect(mailIn('de').subject, 'K7QX-M2PD ist dein Halkyon-Learn-Code');
expect(mailIn('fr').subject, 'K7QX-M2PD is your Halkyon Learn code');
});
test('names the app the host is, in its text too', () {
({String subject, String body}) mailIn(String language) => codeMail(
code: 'K7QX-M2PD',
language: language,
app: 'Halkyon Todo',
instance: 'todo.example.org',
);
expect(mailIn('en').subject, 'K7QX-M2PD is your Halkyon Todo code');
expect(mailIn('en').body, contains('Type this code in Halkyon Todo'));
expect(mailIn('de').subject, 'K7QX-M2PD ist dein Halkyon-Todo-Code');
expect(mailIn('de').body, contains('Gib diesen Code in Halkyon Todo'));
});
});
feat(auth): a password with TOTP on the register, and resets The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
group("a reset's notice", () {
({String subject, String body}) notice(
AccountResetKind kind,
String language,
) => resetMail(
kind: kind,
takesEffectAt: DateTime.utc(2026, 10, 23, 14, 5, 59),
language: language,
app: 'Halkyon Learn',
instance: 'learn.halkyon.eu',
);
test('says what goes when, in UTC, on which instance, and that signing '
'in calls it off, in German for German and English else', () {
for (final kind in AccountResetKind.values) {
for (final language in ['en', 'de', 'fr']) {
final mail = notice(kind, language);
expect(mail.body, contains('2026-10-23 14:05 UTC'));
expect(mail.body, contains('learn.halkyon.eu'));
expect(mail.subject, contains('Halkyon Learn'));
expect(
mail.body,
language == 'de'
? contains('bricht es ab')
: contains('calls it off'),
);
}
}
expect(
notice(AccountResetKind.account, 'en').body,
contains('can no longer be read'),
);
});
test('holds no link and no code: it helps nobody but the owner', () {
for (final kind in AccountResetKind.values) {
final mail = notice(kind, 'en');
expect(mail.body, isNot(contains('http')));
expect(mail.body, isNot(matches(RegExp('[0-9A-Z]{4}-[0-9A-Z]{4}'))));
}
});
});
group('codes go', () {
final smtp = SmtpCodeMailer(
server: SmtpServer('localhost'),
from: const mailer.Address('learn@halkyon.eu'),
app: 'Halkyon Learn',
instance: 'learn.halkyon.eu',
);
test('through the SMTP service set up, in every run mode', () {
for (final runMode in [
ServerpodRunMode.development,
ServerpodRunMode.production,
]) {
expect(codeMailerFor(runMode: runMode, smtp: smtp), same(smtp));
}
});
test('into the log in development without one, and nowhere elsewhere '
'(no email sign-in)', () {
expect(
codeMailerFor(runMode: ServerpodRunMode.development, smtp: null),
isA<LogCodeMailer>(),
);
for (final runMode in [
ServerpodRunMode.staging,
ServerpodRunMode.production,
ServerpodRunMode.test,
]) {
expect(codeMailerFor(runMode: runMode, smtp: null), isNull);
}
});
});
group('over SMTP', () {
late _FakeSmtp server;
setUp(() async => server = await _FakeSmtp.start());
tearDown(() => server.close());
SmtpCodeMailer mailerFor({required bool allowInsecure}) => SmtpCodeMailer(
server: SmtpServer(
'127.0.0.1',
port: server.port,
username: 'learn@halkyon.eu',
password: 'token',
allowInsecure: allowInsecure,
),
from: const mailer.Address('learn@halkyon.eu', 'Halkyon Learn'),
app: 'Halkyon Learn',
instance: 'learn.halkyon.eu',
);
test(
'mails the code from the address set up to the address typed',
() async {
await mailerFor(allowInsecure: true).mail(
to: 'anna@example.org',
code: 'K7QX-M2PD',
language: 'de',
);
expect(server.commands, contains('MAIL FROM:<learn@halkyon.eu>'));
expect(server.commands, contains('RCPT TO:<anna@example.org>'));
final mail = _read(server.mails.single);
expect(mail.headers, contains('Halkyon Learn'));
expect(mail.headers, contains('<learn@halkyon.eu>'));
expect(mail.headers, contains('text/plain'));
expect(mail.body, startsWith('K7QX-M2PD'));
expect(mail.body, contains('auf learn.halkyon.eu anzumelden'));
},
);
test('never over a connection without TLS', () async {
await expectLater(
mailerFor(allowInsecure: false).mail(
to: 'anna@example.org',
code: 'K7QX-M2PD',
language: 'en',
),
throwsA(isA<Exception>()),
);
expect(server.mails, isEmpty);
expect(
server.commands.where((command) => command.startsWith('AUTH')),
isEmpty,
);
});
});
}