feat(train): the Jellyfin token in Train's own store, sealed by the Keystore (ADR 0030, D52)

The token moves from flutter_secure_storage into JellyfinTokens.kt: a
file per person in the no-backup folder, sealed with AES-GCM under a key
of Android's Keystore, the person's ID as associated data, so that the
music job to come can read it as the app does. A token the keyring held
is moved over as it is first read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 18:42:46 +01:00
commit 6bde74f0ba
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,82 @@
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
/// The Jellyfin token in Train's own store on Android (ADR 0030, D52), over
/// the music channel (`JellyfinTokens.kt`, a store in memory here); a token
/// the keyring kept before is moved over once and leaves the keyring.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('app.halkyon.train/music');
late Map<String, String> kept;
setUp(() {
kept = {};
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
final arguments = call.arguments as Map<Object?, Object?>;
final person = arguments['person']! as String;
switch (call.method) {
case 'tokenRead':
return kept[person];
case 'tokenKeep':
if (arguments['token'] case final String token) {
kept[person] = token;
} else {
kept.remove(person);
}
case 'tokenForgetOthers':
kept.removeWhere((key, _) => key != person);
}
return null;
});
});
tearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null),
);
test(
'a token kept is read back from the store, and null forgets it',
() async {
FlutterSecureStorage.setMockInitialValues({});
final tokens = AndroidJellyfinTokenStore('ada');
await tokens.keep('token-1');
expect(await tokens.read(), 'token-1');
expect(kept, {'ada': 'token-1'});
await tokens.keep(null);
expect(await tokens.read(), isNull);
},
);
test("the keyring's token is moved over once and leaves the keyring; "
"others' are forgotten in both", () async {
FlutterSecureStorage.setMockInitialValues({
'train_jellyfin:ada': 'before',
'train_jellyfin:bob': 'his',
'halkyon_account_key': 'not music',
});
kept['bob'] = 'his';
final tokens = AndroidJellyfinTokenStore('ada');
await tokens.forgetOthers();
expect(await tokens.read(), 'before');
const keyring = FlutterSecureStorage();
expect(await keyring.readAll(), {'halkyon_account_key': 'not music'});
expect(kept, {'ada': 'before'});
});
test('a token kept anew leaves no old one in the keyring', () async {
FlutterSecureStorage.setMockInitialValues({'train_jellyfin:ada': 'before'});
final tokens = AndroidJellyfinTokenStore('ada');
await tokens.keep('after');
expect(await tokens.read(), 'after');
expect(await const FlutterSecureStorage().readAll(), isEmpty);
});
}