chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its assets (maps, Anki fixtures, icons), as they are; paths are fixed in the commits that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11abd9afce
commit
649499be23
6 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,222 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_page.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_secrets.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/mcp_protocol.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('Connect codes', () {
|
||||
test('are eight characters of Crockford base 32, in two groups', () {
|
||||
for (var i = 0; i < 100; i++) {
|
||||
expect(
|
||||
newShortCode(),
|
||||
matches(RegExp(r'^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$')),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('read as typed: any case, spaces and dashes, O for 0, I and L '
|
||||
'for 1', () {
|
||||
expect(normalizeShortCode(' k7qx-m2pd '), 'K7QXM2PD');
|
||||
expect(
|
||||
normalizeShortCode('O1IL lo'),
|
||||
'0111'
|
||||
'10',
|
||||
);
|
||||
});
|
||||
|
||||
test('are kept as a hash, never as they are', () {
|
||||
final hash = hashSecret('K7QXM2PD');
|
||||
|
||||
expect(hash, isNot(contains('K7QXM2PD')));
|
||||
expect(hash, hashSecret('K7QXM2PD'));
|
||||
expect(hash, hasLength(64));
|
||||
});
|
||||
});
|
||||
|
||||
group('PKCE', () {
|
||||
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
final challenge = base64UrlEncode(
|
||||
sha256.convert(ascii.encode(verifier)).bytes,
|
||||
).replaceAll('=', '');
|
||||
|
||||
test('a verifier matches its S256 challenge', () {
|
||||
// RFC 7636, appendix B.
|
||||
expect(challenge, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
|
||||
expect(pkceMatches(verifier, challenge), isTrue);
|
||||
});
|
||||
|
||||
test('another verifier, a short one or one with other characters '
|
||||
"doesn't", () {
|
||||
expect(pkceMatches('${verifier}x', challenge), isFalse);
|
||||
expect(pkceMatches('short', challenge), isFalse);
|
||||
expect(pkceMatches('$verifier!', challenge), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('The MCP protocol', () {
|
||||
final tools = [
|
||||
McpTool(
|
||||
name: 'echo',
|
||||
description: 'Says what it was given.',
|
||||
inputSchema: const {'type': 'object'},
|
||||
call: (arguments) async => switch (arguments['text']) {
|
||||
final String text => text,
|
||||
_ => throw const McpToolError('Give a text.'),
|
||||
},
|
||||
),
|
||||
];
|
||||
Future<Map<String, Object?>?> answer(Object? message) => answerMcp(
|
||||
message,
|
||||
tools: tools,
|
||||
server: (name: 'test', title: 'Test', version: '1'),
|
||||
instructions: 'Echoes.',
|
||||
);
|
||||
|
||||
test('initializing agrees on a version this server speaks', () async {
|
||||
final known = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'initialize',
|
||||
'params': {'protocolVersion': '2025-06-18'},
|
||||
});
|
||||
final unknown = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 2,
|
||||
'method': 'initialize',
|
||||
'params': {'protocolVersion': '2024-01-01'},
|
||||
});
|
||||
|
||||
expect(known!['result'], containsPair('protocolVersion', '2025-06-18'));
|
||||
expect(
|
||||
unknown!['result'],
|
||||
containsPair('protocolVersion', mcpProtocolVersions.first),
|
||||
);
|
||||
expect(
|
||||
(known['result']! as Map)['capabilities'],
|
||||
containsPair('tools', isA<Map<String, Object?>>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a notification gets no answer', () async {
|
||||
expect(
|
||||
await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'notifications/initialized',
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('lists its tools, each marked as only reading', () async {
|
||||
final listed = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 3,
|
||||
'method': 'tools/list',
|
||||
});
|
||||
|
||||
final tool =
|
||||
((listed!['result']! as Map)['tools']! as List).single as Map;
|
||||
expect(tool['name'], 'echo');
|
||||
expect(tool['annotations'], containsPair('readOnlyHint', true));
|
||||
expect(tool['annotations'], containsPair('destructiveHint', false));
|
||||
});
|
||||
|
||||
test("calls a tool; a tool's refusal is its result, marked as an "
|
||||
'error', () async {
|
||||
final called = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 4,
|
||||
'method': 'tools/call',
|
||||
'params': {
|
||||
'name': 'echo',
|
||||
'arguments': {'text': 'hi'},
|
||||
},
|
||||
});
|
||||
final refused = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 5,
|
||||
'method': 'tools/call',
|
||||
'params': {'name': 'echo', 'arguments': <String, Object?>{}},
|
||||
});
|
||||
|
||||
expect(called!['result'], {
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'hi'},
|
||||
],
|
||||
'isError': false,
|
||||
});
|
||||
expect((refused!['result']! as Map)['isError'], isTrue);
|
||||
});
|
||||
|
||||
test('an unknown method or tool, or something else than JSON-RPC, is '
|
||||
'an error', () async {
|
||||
final method = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 6,
|
||||
'method': 'resources/list',
|
||||
});
|
||||
final tool = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 7,
|
||||
'method': 'tools/call',
|
||||
'params': {'name': 'nothing'},
|
||||
});
|
||||
|
||||
expect((method!['error']! as Map)['code'], -32601);
|
||||
expect((tool!['error']! as Map)['code'], -32602);
|
||||
expect(((await answer([1]))!['error']! as Map)['code'], -32600);
|
||||
expect(decodeMcp('{').parseError, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('The authorization page', () {
|
||||
AuthorizePage page(String client) => (
|
||||
language: PageLanguage.en,
|
||||
client: client,
|
||||
returnsTo: 'claude.ai',
|
||||
instance: 'learn.example.org',
|
||||
fields: {'state': '"><script>'},
|
||||
);
|
||||
|
||||
test('escapes what a client names itself and carries', () {
|
||||
final html = authorizePage(page('<b>Evil</b>'));
|
||||
|
||||
expect(html, contains('<b>Evil</b>'));
|
||||
expect(html, isNot(contains('<b>Evil')));
|
||||
expect(html, isNot(contains('"><script>')));
|
||||
});
|
||||
|
||||
test('names where the person returns to, and how to get a code', () {
|
||||
final html = authorizePage(page('Claude'));
|
||||
|
||||
expect(html, contains('<strong>claude.ai</strong>'));
|
||||
expect(html, contains('Settings › Assistant'));
|
||||
expect(html, contains('name="connect_code"'));
|
||||
});
|
||||
|
||||
test('speaks German where the browser prefers it', () {
|
||||
expect(pageLanguage('de-DE,de;q=0.9,en;q=0.8'), PageLanguage.de);
|
||||
expect(pageLanguage('en-GB,de;q=0.5'), PageLanguage.en);
|
||||
expect(pageLanguage(null), PageLanguage.en);
|
||||
});
|
||||
|
||||
test('may not be framed, and may only post here and to the client', () {
|
||||
final headers = pageHeaders(
|
||||
Uri.parse('https://claude.ai/api/mcp/auth_callback'),
|
||||
);
|
||||
|
||||
expect(
|
||||
headers['Content-Security-Policy'],
|
||||
allOf(
|
||||
contains("frame-ancestors 'none'"),
|
||||
contains("form-action 'self' https://claude.ai"),
|
||||
),
|
||||
);
|
||||
expect(headers['X-Frame-Options'], 'DENY');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test("Learn's instances name the app `learn`, the only instances Learn's "
|
||||
'apps accept', () {
|
||||
expect(learnBase().app, 'learn');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_server/src/instance/jellyfin_suggestion.dart';
|
||||
import 'package:halkyon_learn_server/src/instance/public_suffix.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('The public suffix list', () {
|
||||
// A few rules as the list writes them, of each kind.
|
||||
final suffixes = PublicSuffixList.parse('''
|
||||
// A comment, and an empty line below.
|
||||
|
||||
eu
|
||||
li
|
||||
uk
|
||||
co.uk
|
||||
org
|
||||
duckdns.org
|
||||
*.ck
|
||||
!www.ck
|
||||
''');
|
||||
|
||||
test('tells the domain registered for a host', () {
|
||||
expect(suffixes.registrableDomain('learn.halkyon.eu'), 'halkyon.eu');
|
||||
expect(suffixes.registrableDomain('halkyon.li'), 'halkyon.li');
|
||||
expect(suffixes.registrableDomain('Learn.Halkyon.EU'), 'halkyon.eu');
|
||||
});
|
||||
|
||||
test('knows suffixes of two labels, private ones too: no foreign '
|
||||
'jellyfin.co.uk for an instance at example.co.uk', () {
|
||||
expect(suffixes.registrableDomain('example.co.uk'), 'example.co.uk');
|
||||
expect(
|
||||
suffixes.registrableDomain('learn.example.co.uk'),
|
||||
'example.co.uk',
|
||||
);
|
||||
expect(
|
||||
suffixes.registrableDomain('halkyon.duckdns.org'),
|
||||
'halkyon.duckdns.org',
|
||||
);
|
||||
});
|
||||
|
||||
test('follows wildcards and their exceptions', () {
|
||||
expect(suffixes.registrableDomain('a.b.ck'), 'a.b.ck');
|
||||
expect(suffixes.registrableDomain('www.ck'), 'www.ck');
|
||||
});
|
||||
|
||||
test('has no registered domain for a suffix, a single name or an '
|
||||
'address', () {
|
||||
expect(suffixes.registrableDomain('co.uk'), isNull);
|
||||
expect(suffixes.registrableDomain('localhost'), isNull);
|
||||
expect(suffixes.registrableDomain('192.168.1.10'), isNull);
|
||||
expect(suffixes.registrableDomain('::1'), isNull);
|
||||
expect(suffixes.registrableDomain(''), isNull);
|
||||
});
|
||||
|
||||
test("Mozilla's list as the server holds it reads", () {
|
||||
final list = PublicSuffixList.read();
|
||||
expect(list.registrableDomain('learn.halkyon.eu'), 'halkyon.eu');
|
||||
expect(list.registrableDomain('learn.example.co.uk'), 'example.co.uk');
|
||||
expect(list.registrableDomain('me.github.io'), 'me.github.io');
|
||||
});
|
||||
});
|
||||
|
||||
group("The instance's Jellyfin server", () {
|
||||
final suffixes = PublicSuffixList.parse('eu\nli\n');
|
||||
final asked = <Uri>[];
|
||||
setUp(asked.clear);
|
||||
|
||||
JellyfinSuggestion suggestion({
|
||||
String? configured,
|
||||
String? host = 'learn.halkyon.eu',
|
||||
bool answers = true,
|
||||
}) => JellyfinSuggestion(
|
||||
configured: configured,
|
||||
host: host,
|
||||
suffixes: () => suffixes,
|
||||
answersAsJellyfin: (address) async {
|
||||
asked.add(address);
|
||||
return answers;
|
||||
},
|
||||
);
|
||||
|
||||
test("is the operator's, as set, asking nothing", () async {
|
||||
final found = suggestion(configured: 'https://media.example.org/');
|
||||
|
||||
expect(found.url, Uri.parse('https://media.example.org/'));
|
||||
await found.look();
|
||||
expect(asked, isEmpty);
|
||||
});
|
||||
|
||||
test('is none when the operator set it to none', () async {
|
||||
final found = suggestion(configured: 'none');
|
||||
|
||||
await found.look();
|
||||
expect(found.url, isNull);
|
||||
expect(asked, isEmpty);
|
||||
});
|
||||
|
||||
test('set empty, as Compose passes an unset one, it is looked for as '
|
||||
'unset', () async {
|
||||
final found = suggestion(configured: '');
|
||||
|
||||
await found.look();
|
||||
expect(found.url, Uri.parse('https://jellyfin.halkyon.eu/'));
|
||||
});
|
||||
|
||||
test('else jellyfin. on the domain registered for the instance, once it '
|
||||
'answers as a Jellyfin server', () async {
|
||||
final found = suggestion();
|
||||
expect(found.url, isNull);
|
||||
|
||||
await found.look();
|
||||
|
||||
expect(asked, [Uri.parse('https://jellyfin.halkyon.eu/')]);
|
||||
expect(found.url, Uri.parse('https://jellyfin.halkyon.eu/'));
|
||||
});
|
||||
|
||||
test('is none where nothing answers so, and none for an instance on '
|
||||
'localhost', () async {
|
||||
final silent = suggestion(answers: false);
|
||||
await silent.look();
|
||||
expect(silent.url, isNull);
|
||||
|
||||
asked.clear();
|
||||
final local = suggestion(host: 'localhost');
|
||||
await local.look();
|
||||
expect(local.url, isNull);
|
||||
expect(asked, isEmpty);
|
||||
});
|
||||
|
||||
test('looks again an hour after, as it is asked for', () async {
|
||||
var now = DateTime.utc(2026, 10, 13, 22);
|
||||
await withClock(Clock(() => now), () async {
|
||||
final found = suggestion();
|
||||
await found.look();
|
||||
found.url;
|
||||
expect(asked, hasLength(1));
|
||||
|
||||
now = now.add(const Duration(minutes: 59));
|
||||
found.url;
|
||||
expect(asked, hasLength(1));
|
||||
|
||||
now = now.add(const Duration(minutes: 2));
|
||||
found.url;
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(asked, hasLength(2));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Whether an address answers as a Jellyfin server', () {
|
||||
late HttpServer server;
|
||||
late Uri address;
|
||||
var product = 'Jellyfin Server';
|
||||
setUp(() async {
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
address = Uri.parse('http://127.0.0.1:${server.port}/');
|
||||
server.listen((request) {
|
||||
if (request.uri.path == '/System/Info/Public') {
|
||||
request.response
|
||||
..headers.contentType = ContentType.json
|
||||
..write(jsonEncode({'ProductName': product, 'Version': '12.2.0'}));
|
||||
} else {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
}
|
||||
unawaited(request.response.close());
|
||||
});
|
||||
});
|
||||
tearDown(() => server.close(force: true));
|
||||
|
||||
test('it does when its public information names Jellyfin', () async {
|
||||
product = 'Jellyfin Server';
|
||||
expect(await answersAsJellyfin(address), isTrue);
|
||||
});
|
||||
|
||||
test('anything else does not', () async {
|
||||
product = 'Emby Server';
|
||||
expect(await answersAsJellyfin(address), isFalse);
|
||||
expect(
|
||||
await answersAsJellyfin(address.resolve('elsewhere/')),
|
||||
isFalse,
|
||||
);
|
||||
await server.close(force: true);
|
||||
expect(await answersAsJellyfin(address), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_server/src/profile/link_verification.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('a public address', () {
|
||||
test('is one anybody on the internet reaches', () {
|
||||
for (final address in ['93.184.216.34', '1.1.1.1', '2606:4700::1111']) {
|
||||
expect(
|
||||
isPublicAddress(InternetAddress(address)),
|
||||
isTrue,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'is never the instance itself, its networks, or addresses kept for '
|
||||
"special uses: the instance mustn't be made to look around itself",
|
||||
() {
|
||||
for (final address in [
|
||||
'127.0.0.1', // loopback
|
||||
'0.0.0.0', // this network
|
||||
'10.0.0.1', // private
|
||||
'172.16.5.4',
|
||||
'172.30.80.2', // the instance's edge network, Caddy
|
||||
'192.168.1.1',
|
||||
'169.254.169.254', // link-local, cloud metadata
|
||||
'100.64.0.1', // shared address space (carrier-grade NAT)
|
||||
'192.0.0.8', // IETF protocol assignments
|
||||
'192.0.2.1', // documentation
|
||||
'198.51.100.1',
|
||||
'203.0.113.1',
|
||||
'198.18.0.1', // benchmarking
|
||||
'224.0.0.1', // multicast
|
||||
'240.0.0.1', // reserved
|
||||
'255.255.255.255', // broadcast
|
||||
'::1',
|
||||
'::',
|
||||
'fe80::1',
|
||||
'fc00::1', // unique local
|
||||
'fd12:3456::1',
|
||||
'ff02::1', // multicast
|
||||
'2001:db8::1', // documentation
|
||||
'::ffff:127.0.0.1', // IPv4 inside IPv6
|
||||
'::ffff:10.0.0.1',
|
||||
'64:ff9b::7f00:1', // NAT64 of 127.0.0.1
|
||||
]) {
|
||||
expect(
|
||||
isPublicAddress(InternetAddress(address)),
|
||||
isFalse,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('a page links back', () {
|
||||
final profile = Uri.parse('https://halkyon.eu/@Raisondetredev');
|
||||
final page = Uri.parse('https://example.org/about');
|
||||
|
||||
bool linksBack(String html, {Uri? at}) =>
|
||||
linksBackTo(html, pageUrl: at ?? page, profileUrl: profile);
|
||||
|
||||
test('with an <a> or a <link> marked rel="me" to the profile page, its '
|
||||
"host's and its handle's case and a trailing slash aside", () {
|
||||
for (final html in [
|
||||
'<a rel="me" href="https://halkyon.eu/@Raisondetredev">Halkyon</a>',
|
||||
'<head><link rel="me" href="https://halkyon.eu/@raisondetredev/"></head>',
|
||||
'<a href="https://halkyon.eu/@raisondetredev" rel="noopener me">x</a>',
|
||||
"<A REL='Me' HREF='https://HALKYON.EU/@RAISONDETREDEV'>x</A>",
|
||||
'<a rel="me" href="https://halkyon.eu/@raisondetredev?from=site#top">',
|
||||
]) {
|
||||
expect(linksBack(html), isTrue, reason: html);
|
||||
}
|
||||
expect(
|
||||
linksBack(
|
||||
'<a rel="me" href="/@raisondetredev">x</a>',
|
||||
at: Uri.parse('https://halkyon.eu/about'),
|
||||
),
|
||||
isTrue,
|
||||
reason: 'a relative link, from the page it is on',
|
||||
);
|
||||
});
|
||||
|
||||
test('not with a link without rel="me", to another page, another '
|
||||
'person, another host or over plain HTTP', () {
|
||||
for (final html in [
|
||||
'<a href="https://halkyon.eu/@raisondetredev">x</a>',
|
||||
'<a rel="nofollow" href="https://halkyon.eu/@raisondetredev">x</a>',
|
||||
'<a rel="meme" href="https://halkyon.eu/@raisondetredev">x</a>',
|
||||
'<a rel="me" href="https://halkyon.eu/@raisondetredev2">x</a>',
|
||||
'<a rel="me" href="https://halkyon.eu/@raisondetredev/studysets">x</a>',
|
||||
'<a rel="me" href="https://learn.halkyon.eu/@raisondetredev">x</a>',
|
||||
'<a rel="me" href="http://halkyon.eu/@raisondetredev">x</a>',
|
||||
'<p>rel="me" href="https://halkyon.eu/@raisondetredev"</p>',
|
||||
]) {
|
||||
expect(linksBack(html), isFalse, reason: html);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('the fetcher refuses, without connecting,', () {
|
||||
final fetcher = SafeLinkFetcher(userAgent: 'test');
|
||||
|
||||
test('what is no HTTPS link on the usual port', () async {
|
||||
for (final link in [
|
||||
'http://example.org/',
|
||||
'ftp://example.org/',
|
||||
'https://example.org:8443/',
|
||||
]) {
|
||||
expect(await fetcher.fetch(Uri.parse(link)), isNull, reason: link);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'a host that is, or resolves to, an address that is not public',
|
||||
() async {
|
||||
for (final link in [
|
||||
'https://127.0.0.1/',
|
||||
'https://[::1]/',
|
||||
'https://localhost/',
|
||||
'https://169.254.169.254/latest/meta-data/',
|
||||
]) {
|
||||
expect(await fetcher.fetch(Uri.parse(link)), isNull, reason: link);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:halkyon_learn_server/src/publish/release_license.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('licences that allow passing content on allow publishing', () {
|
||||
for (final license in [
|
||||
'CC0-1.0',
|
||||
'Public domain',
|
||||
'CC-BY-4.0',
|
||||
'CC-BY-SA-3.0-US',
|
||||
'CC-BY-NC-SA-4.0',
|
||||
'CC-BY-ND-4.0',
|
||||
'cc-by-nc-nd-4.0',
|
||||
'ODbL-1.0',
|
||||
'GFDL-1.3-no-invariants-or-later',
|
||||
'GPL-3.0-or-later',
|
||||
'AGPL-3.0-only',
|
||||
'MIT',
|
||||
'Apache-2.0',
|
||||
]) {
|
||||
expect(allowsPublishing(license), isTrue, reason: license);
|
||||
}
|
||||
});
|
||||
|
||||
test('no licence, all rights reserved, or one not known to be open '
|
||||
"doesn't", () {
|
||||
for (final license in [
|
||||
null,
|
||||
'',
|
||||
' ',
|
||||
'All Rights Reserved',
|
||||
'GPLv3',
|
||||
'Proprietary',
|
||||
'CC-BY',
|
||||
'CC-SA-1.0',
|
||||
]) {
|
||||
expect(allowsPublishing(license), isFalse, reason: license);
|
||||
}
|
||||
});
|
||||
|
||||
test('ShareAlike and copyleft licences keep adaptations under them', () {
|
||||
for (final license in [
|
||||
'CC-BY-SA-4.0',
|
||||
'CC-BY-NC-SA-3.0-US',
|
||||
'GPL-3.0-or-later',
|
||||
'GFDL-1.3-only',
|
||||
'ODbL-1.0',
|
||||
]) {
|
||||
expect(keepsAdaptations(license), isTrue, reason: license);
|
||||
}
|
||||
for (final license in ['CC-BY-4.0', 'CC0-1.0', 'MIT', 'CC-BY-ND-4.0']) {
|
||||
expect(keepsAdaptations(license), isFalse, reason: license);
|
||||
}
|
||||
});
|
||||
|
||||
test('"no derivatives" forbids passing on adaptations', () {
|
||||
expect(forbidsAdaptations('CC-BY-ND-4.0'), isTrue);
|
||||
expect(forbidsAdaptations('CC-BY-NC-ND-3.0-DE'), isTrue);
|
||||
expect(forbidsAdaptations('CC-BY-SA-4.0'), isFalse);
|
||||
expect(forbidsAdaptations('MIT'), isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory folder;
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('release-repository-');
|
||||
});
|
||||
tearDown(() => folder.delete(recursive: true));
|
||||
|
||||
const someone = (name: 'Someone', email: 'someone@example.org');
|
||||
|
||||
test('reads a release of thousands of files back, as large as a '
|
||||
"language's vocabulary, without waiting on git for ever", () async {
|
||||
// git answers each object as it reads its name: once its answers fill
|
||||
// the pipe, it reads no more names until they are read, so they must
|
||||
// be read while the names are still being written.
|
||||
final repository = ReleaseRepository(Directory('${folder.path}/r.git'));
|
||||
Uint8List cardOf(int index) => utf8.encode(
|
||||
jsonEncode({'id': 'card-$index', 'payload': 'x' * 900}),
|
||||
);
|
||||
await repository.release(
|
||||
files: {
|
||||
for (var index = 0; index < 3000; index++)
|
||||
'cards/card-$index.json': () async => cardOf(index),
|
||||
},
|
||||
version: 1,
|
||||
message: 'v1',
|
||||
author: someone,
|
||||
committer: someone,
|
||||
at: DateTime.utc(2026, 10, 16),
|
||||
);
|
||||
|
||||
final read = await repository
|
||||
.readJson(1)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
expect(read, hasLength(3000));
|
||||
expect(read['cards/card-42.json'], utf8.decode(cardOf(42)));
|
||||
}, timeout: const Timeout(Duration(minutes: 2)));
|
||||
}
|
||||
Loading…
Reference in a new issue