halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps
What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.
An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 15:18:49 +02:00
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
import 'package:halkyon_app/halkyon_app.dart';
|
|
|
|
|
import 'package:halkyon_app/testing.dart';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
final builtIn = Instance.parse('learn.halkyon.eu')!;
|
|
|
|
|
final other = Instance.parse('learn.example.org')!;
|
|
|
|
|
late MemoryInstanceStore store;
|
|
|
|
|
late List<Instance> forgotten;
|
|
|
|
|
|
|
|
|
|
InstanceChoice choice() => InstanceChoice(
|
|
|
|
|
store,
|
|
|
|
|
builtIn: builtIn,
|
|
|
|
|
forget: (instance) async {
|
|
|
|
|
forgotten.add(instance);
|
|
|
|
|
},
|
2026-10-21 16:28:14 +02:00
|
|
|
sharesStorage: InstanceStorage('/support', builtIn: builtIn).sharesWith,
|
halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps
What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.
An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 15:18:49 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
store = MemoryInstanceStore();
|
|
|
|
|
forgotten = [];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a new install has no instance until one is chosen', () async {
|
|
|
|
|
final instances = choice();
|
|
|
|
|
await instances.load(usedBefore: false);
|
|
|
|
|
|
|
|
|
|
expect(instances.current, isNull);
|
|
|
|
|
expect(instances.known, isEmpty);
|
|
|
|
|
|
|
|
|
|
await instances.use(other);
|
|
|
|
|
expect(instances.current, other);
|
|
|
|
|
expect(store.kept?.current, other);
|
|
|
|
|
expect(store.kept?.known, [other]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an install from before there was a choice goes on with the '
|
|
|
|
|
"build's instance", () async {
|
|
|
|
|
final instances = choice();
|
|
|
|
|
await instances.load(usedBefore: true);
|
|
|
|
|
|
|
|
|
|
expect(instances.current, builtIn);
|
|
|
|
|
expect(store.kept?.current, builtIn);
|
|
|
|
|
expect(store.kept?.known, [builtIn]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('what was chosen is read back', () async {
|
|
|
|
|
store = MemoryInstanceStore((current: other, known: [builtIn, other]));
|
|
|
|
|
final instances = choice();
|
|
|
|
|
await instances.load(usedBefore: true);
|
|
|
|
|
|
|
|
|
|
expect(instances.current, other);
|
|
|
|
|
expect(instances.known, [builtIn, other]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('switching keeps every account; removing one forgets what the '
|
|
|
|
|
'device holds of it, never of the one in use', () async {
|
|
|
|
|
final instances = choice();
|
|
|
|
|
await instances.load(usedBefore: true);
|
|
|
|
|
await instances.use(other);
|
|
|
|
|
await instances.use(builtIn);
|
|
|
|
|
expect(instances.known, [builtIn, other]);
|
|
|
|
|
|
|
|
|
|
await instances.remove(builtIn);
|
|
|
|
|
expect(forgotten, isEmpty, reason: 'in use');
|
|
|
|
|
|
|
|
|
|
await instances.remove(other);
|
|
|
|
|
expect(forgotten, [other]);
|
|
|
|
|
expect(instances.known, [builtIn]);
|
|
|
|
|
expect(store.kept?.current, builtIn);
|
|
|
|
|
expect(store.kept?.known, [builtIn]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('tells its listeners of every change', () async {
|
|
|
|
|
final instances = choice();
|
|
|
|
|
var changes = 0;
|
|
|
|
|
instances.addListener(() => changes++);
|
|
|
|
|
|
|
|
|
|
await instances.load(usedBefore: false);
|
|
|
|
|
await instances.use(other);
|
|
|
|
|
await instances.use(builtIn);
|
|
|
|
|
await instances.remove(other);
|
|
|
|
|
|
|
|
|
|
expect(changes, 4);
|
|
|
|
|
});
|
fix(app)!: instances over plain HTTP are refused, but on this machine
What the app sends an instance (the proofs that sign in, the keys'
wrappings, every request a session carries) must not be read or changed on
the way, so an instance is reached over HTTPS, or over plain HTTP only on
this machine (localhost, 127.0.0.0/8, ::1), as Jellyfin servers are. An
address over plain HTTP elsewhere is refused before anything is sent there.
One kept from before is never opened: the app says why, and offers another
instance or removing it from the device, never dropping it silently.
Development builds still use their own instance over HTTP (an emulator
reaches the developer's machine as 10.0.2.2); releases never.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 14:35:21 +02:00
|
|
|
|
|
|
|
|
test('allows an instance reached securely, and over plain HTTP elsewhere '
|
|
|
|
|
"only the build's own in a development build", () {
|
|
|
|
|
final insecure = Instance.parse('http://10.0.2.2:8080')!;
|
|
|
|
|
// A development build, as tests run.
|
|
|
|
|
final development = InstanceChoice(
|
|
|
|
|
store,
|
|
|
|
|
builtIn: insecure,
|
|
|
|
|
forget: (instance) async {},
|
|
|
|
|
);
|
|
|
|
|
expect(development.allows(builtIn), isTrue);
|
|
|
|
|
expect(
|
|
|
|
|
development.allows(Instance.parse('http://localhost:8080')!),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
expect(development.allows(insecure), isTrue);
|
|
|
|
|
expect(
|
|
|
|
|
development.allows(Instance.parse('http://learn.example.org')!),
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final release = InstanceChoice(
|
|
|
|
|
store,
|
|
|
|
|
builtIn: insecure,
|
|
|
|
|
forget: (instance) async {},
|
|
|
|
|
allowsInsecureBuiltIn: false,
|
|
|
|
|
);
|
|
|
|
|
expect(release.allows(insecure), isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('leaves an instance it no longer allows for another, forgetting it '
|
|
|
|
|
'if asked', () async {
|
|
|
|
|
final insecure = Instance.parse('http://learn.example.net')!;
|
|
|
|
|
store = MemoryInstanceStore((
|
|
|
|
|
current: insecure,
|
|
|
|
|
known: [other, insecure, builtIn],
|
|
|
|
|
));
|
|
|
|
|
final instances = choice();
|
|
|
|
|
await instances.load(usedBefore: false);
|
|
|
|
|
expect(instances.current, insecure, reason: 'never dropped silently');
|
|
|
|
|
|
|
|
|
|
await instances.leave(insecure, forget: false);
|
|
|
|
|
expect(instances.current, other, reason: 'the first allowed one');
|
|
|
|
|
expect(instances.known, [other, insecure, builtIn]);
|
|
|
|
|
expect(forgotten, isEmpty);
|
|
|
|
|
|
|
|
|
|
await instances.use(insecure);
|
|
|
|
|
await instances.leave(insecure, forget: true);
|
|
|
|
|
expect(instances.current, other);
|
|
|
|
|
expect(instances.known, [other, builtIn]);
|
|
|
|
|
expect(forgotten, [insecure]);
|
|
|
|
|
expect(store.kept?.known, [other, builtIn]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("leaving the only one goes to the build's own", () async {
|
|
|
|
|
final insecure = Instance.parse('http://learn.example.net')!;
|
|
|
|
|
store = MemoryInstanceStore((current: insecure, known: [insecure]));
|
|
|
|
|
final instances = choice();
|
|
|
|
|
await instances.load(usedBefore: false);
|
|
|
|
|
|
|
|
|
|
await instances.leave(insecure, forget: true);
|
|
|
|
|
expect(instances.current, builtIn);
|
|
|
|
|
expect(instances.known, [builtIn]);
|
|
|
|
|
expect(forgotten, [insecure]);
|
|
|
|
|
});
|
2026-10-21 16:28:14 +02:00
|
|
|
|
|
|
|
|
group('two addresses the device keeps in one place (http:// and https:// '
|
|
|
|
|
'on one host; the reading of the fixes)', () {
|
|
|
|
|
final insecure = Instance.parse('http://learn.example.org')!;
|
|
|
|
|
|
|
|
|
|
test('are one account: choosing one puts it in the place of the other, '
|
|
|
|
|
'forgetting nothing', () async {
|
|
|
|
|
store = MemoryInstanceStore((
|
|
|
|
|
current: insecure,
|
|
|
|
|
known: [builtIn, insecure],
|
|
|
|
|
));
|
|
|
|
|
final instances = choice();
|
|
|
|
|
await instances.load(usedBefore: false);
|
|
|
|
|
|
|
|
|
|
await instances.use(other);
|
|
|
|
|
|
|
|
|
|
expect(instances.current, other);
|
|
|
|
|
expect(instances.known, [builtIn, other]);
|
|
|
|
|
expect(forgotten, isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('removing one never forgets the folder, session or keys the other '
|
|
|
|
|
'uses', () async {
|
|
|
|
|
// Both known, from before.
|
|
|
|
|
store = MemoryInstanceStore((
|
|
|
|
|
current: insecure,
|
|
|
|
|
known: [builtIn, insecure, other],
|
|
|
|
|
));
|
|
|
|
|
final instances = choice();
|
|
|
|
|
await instances.load(usedBefore: false);
|
|
|
|
|
|
|
|
|
|
// Refused, and left for good, as the screen for an address over
|
|
|
|
|
// plain HTTP offers.
|
|
|
|
|
await instances.leave(insecure, forget: true);
|
|
|
|
|
expect(instances.known, [builtIn, other]);
|
|
|
|
|
expect(forgotten, isEmpty);
|
|
|
|
|
|
|
|
|
|
store = MemoryInstanceStore((
|
|
|
|
|
current: other,
|
|
|
|
|
known: [builtIn, insecure, other],
|
|
|
|
|
));
|
|
|
|
|
final again = choice();
|
|
|
|
|
await again.load(usedBefore: false);
|
|
|
|
|
await again.remove(insecure);
|
|
|
|
|
expect(again.known, [builtIn, other]);
|
|
|
|
|
expect(forgotten, isEmpty);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the storage tells which addresses it keeps in one place, the '
|
|
|
|
|
"build's own apart", () {
|
|
|
|
|
final storage = InstanceStorage('/support', builtIn: builtIn);
|
|
|
|
|
final insecure = Instance.parse('http://learn.example.org')!;
|
|
|
|
|
final builtInsTwin = Instance.parse('http://learn.halkyon.eu')!;
|
|
|
|
|
|
|
|
|
|
expect(storage.sharesWith(insecure, other), isTrue);
|
|
|
|
|
expect(storage.sharesWith(other, other), isFalse);
|
|
|
|
|
expect(storage.sharesWith(builtIn, builtInsTwin), isFalse);
|
|
|
|
|
expect(
|
|
|
|
|
storage.sharesWith(other, Instance.parse('learn.example.net')!),
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
// Their places as before: none moved.
|
|
|
|
|
expect(
|
|
|
|
|
storage.peopleOf(other).path,
|
|
|
|
|
'/support/instances/learn.example.org/people',
|
|
|
|
|
);
|
|
|
|
|
expect(storage.peopleOf(builtIn).path, '/support/people');
|
|
|
|
|
});
|
halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps
What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.
An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 15:18:49 +02:00
|
|
|
}
|