222 lines
6.7 KiB
Dart
222 lines
6.7 KiB
Dart
|
|
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'));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
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,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|