Some checks failed
CI / check (push) Has been cancelled
first-start.md takes the instance from a bare host to running, once: the sidecars' users, deploy with a read-only key to the forge and the developer's keys (GPG for redeploy.sh's check, SSH pinned to it), .env, SimpleLogin's app, the sidecars' folders and Jellyfin moved in, a role and database for each app's server, the first start, the first operator by ADR 0021's SQL, and the backups. halkyon-li-env.sh writes .env with every secret made on the host and never over an existing file, as the peppers must not change once people sign in: SimpleLogin alone with approval, everything running behind the forward, Train's map and routes over southern Bavaria and its search over Germany until the Instance console cuts regions, Germany as where data and backups are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
142 lines
4.8 KiB
Dart
142 lines
4.8 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
/// halkyon.li's first `.env` (ADR 0039; the developer's choices of 8 Nov 2026):
|
|
/// every secret made fresh on the host, halkyon.li's settings, and never an existing
|
|
/// file overwritten, as the session peppers must never change once people sign in.
|
|
void main() {
|
|
const script = 'deploy/host/halkyon-li-env.sh';
|
|
|
|
late Directory folder;
|
|
setUp(() => folder = Directory.systemTemp.createTempSync('halkyon-li-env'));
|
|
tearDown(() => folder.deleteSync(recursive: true));
|
|
|
|
ProcessResult make(String target) => Process.runSync('sh', [script, target]);
|
|
|
|
Map<String, String> settings(String text) => {
|
|
for (final line in text.split('\n'))
|
|
if (line.isNotEmpty && !line.startsWith('#'))
|
|
line.substring(0, line.indexOf('=')): line.substring(
|
|
line.indexOf('=') + 1,
|
|
),
|
|
};
|
|
|
|
group('a new .env', () {
|
|
late Map<String, String> env;
|
|
late File file;
|
|
|
|
setUp(() {
|
|
file = File('${folder.path}/.env');
|
|
final result = make(file.path);
|
|
expect(result.exitCode, 0, reason: '${result.stderr}');
|
|
env = settings(file.readAsStringSync());
|
|
});
|
|
|
|
test('is readable by its owner alone', () {
|
|
expect(file.statSync().modeString(), 'rw-------');
|
|
});
|
|
|
|
test("makes every secret fresh, each of 32 bytes, none another's", () {
|
|
const secrets = [
|
|
'INSTANCE_SECRET',
|
|
'LEARN_POSTGRES_PASSWORD',
|
|
'LEARN_SESSION_KEY_HASH_PEPPER',
|
|
'SIGN_IN_DECOY_KEY',
|
|
'TRAIN_POSTGRES_PASSWORD',
|
|
'TRAIN_SESSION_KEY_HASH_PEPPER',
|
|
'MAP_KEY_SECRET',
|
|
'TODO_POSTGRES_PASSWORD',
|
|
'TODO_SESSION_KEY_HASH_PEPPER',
|
|
'MEDITATE_POSTGRES_PASSWORD',
|
|
'MEDITATE_SESSION_KEY_HASH_PEPPER',
|
|
];
|
|
for (final name in secrets) {
|
|
expect(env[name], matches(RegExp(r'^[0-9a-f]{64}$')), reason: name);
|
|
}
|
|
expect(
|
|
secrets.map((name) => env[name]).toSet(),
|
|
hasLength(secrets.length),
|
|
);
|
|
});
|
|
|
|
test('differs from the next one made', () {
|
|
final other = File('${folder.path}/other.env');
|
|
make(other.path);
|
|
expect(
|
|
settings(other.readAsStringSync())['INSTANCE_SECRET'],
|
|
isNot(env['INSTANCE_SECRET']),
|
|
);
|
|
});
|
|
|
|
test("serves halkyon.li's names, handles on halkyon.li itself (D7)", () {
|
|
expect(env['LEARN_DOMAIN'], 'learn.halkyon.li');
|
|
expect(env['LEARN_PUBLIC_HOST'], 'learn.halkyon.li');
|
|
expect(env['ACCOUNT_DOMAIN'], 'halkyon.li');
|
|
expect(env['TRAIN_DOMAIN'], 'train.halkyon.li');
|
|
expect(env['TODO_DOMAIN'], 'todo.halkyon.li');
|
|
expect(env['MEDITATE_DOMAIN'], 'meditate.halkyon.li');
|
|
expect(env['JELLYFIN_DOMAIN'], 'jellyfin.halkyon.li');
|
|
});
|
|
|
|
test('lets people in through SimpleLogin alone, once an operator '
|
|
'approves them, and mails nothing', () {
|
|
expect(env['SIGN_IN_WAYS'], 'simplelogin');
|
|
expect(env['SIGN_UP'], 'approval');
|
|
expect(env['SIMPLELOGIN_CLIENT_ID'], isEmpty);
|
|
expect(env['SIMPLELOGIN_CLIENT_SECRET'], isEmpty);
|
|
for (final name in [
|
|
'EMAIL_SIGN_IN_PEPPER',
|
|
'EMAIL_SMTP_HOST',
|
|
'TOTP_KEY',
|
|
'RECOVERY_CODE_KEY',
|
|
]) {
|
|
expect(env[name] ?? '', isEmpty, reason: name);
|
|
}
|
|
});
|
|
|
|
test('runs everything chosen, behind the forward, each file there', () {
|
|
final files = env['COMPOSE_FILE']!.split(':');
|
|
expect(files, [
|
|
'compose.learn.yaml',
|
|
'compose.jellyfin.yaml',
|
|
'compose.languagetool.yaml',
|
|
'compose.train.yaml',
|
|
'compose.maps.yaml',
|
|
'compose.search.yaml',
|
|
'compose.todo.yaml',
|
|
'compose.todo-search.yaml',
|
|
'compose.meditate.yaml',
|
|
'compose.forwarded.yaml',
|
|
]);
|
|
for (final name in files) {
|
|
expect(File('deploy/$name').existsSync(), isTrue, reason: name);
|
|
}
|
|
});
|
|
|
|
test("keeps Train's map and routes to southern Bavaria, its search to "
|
|
'Germany, in memory beside the rest', () {
|
|
expect(env['MAP_REGION'], '10,47.2,13.9,49');
|
|
expect(env['BROUTER_REGION'], '10,45,15,50');
|
|
expect(env['PHOTON_REGION'], 'europe/germany');
|
|
expect(env['PHOTON_HEAP'], '2g');
|
|
expect(env['PHOTON_MEMORY'], '3g');
|
|
});
|
|
|
|
test('says Germany for the data and the backups, and reads the library '
|
|
'from the HDD', () {
|
|
expect(env['DATA_LOCATION'], 'DE');
|
|
expect(env['BACKUP_LOCATION'], 'DE');
|
|
expect(env['JELLYFIN_MEDIA'], '/data/media');
|
|
expect(env.containsKey('MEDITATE_LIBRARY'), isFalse);
|
|
});
|
|
});
|
|
|
|
test('never overwrites an existing .env, whose peppers must stay', () {
|
|
final file = File('${folder.path}/.env')..writeAsStringSync('KEPT=1\n');
|
|
final result = make(file.path);
|
|
expect(result.exitCode, isNot(0));
|
|
expect(result.stderr, contains('exists'));
|
|
expect(file.readAsStringSync(), 'KEPT=1\n');
|
|
});
|
|
}
|