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
1 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,949 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/assistant_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_http.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_mcp.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_oauth.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// Where Claude's custom connectors send people back to.
|
||||
const _callback = 'https://claude.ai/api/mcp/auth_callback';
|
||||
|
||||
/// A PKCE verifier, and its S256 challenge.
|
||||
const _verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
final _challenge = base64UrlEncode(
|
||||
sha256.convert(ascii.encode(_verifier)).bytes,
|
||||
).replaceAll('=', '');
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'The assistant connector',
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late UuidValue alice;
|
||||
late UuidValue bob;
|
||||
final connector = Connector(
|
||||
base: 'https://learn.example.org',
|
||||
instance: 'learn.example.org',
|
||||
);
|
||||
|
||||
/// The people a test made, whose spaces it deletes again.
|
||||
final people = <UuidValue>{};
|
||||
|
||||
setUp(() {
|
||||
// The test pod is built without the generated Serverpod class, which
|
||||
// sets up sync for the running server.
|
||||
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
alice = const Uuid().v7obj();
|
||||
bob = const Uuid().v7obj();
|
||||
people.addAll([alice, bob]);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
// The clients cascade into connections, grants and tokens.
|
||||
await AssistantClient.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => Constant.bool(true),
|
||||
);
|
||||
await AssistantConnectCode.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet(people),
|
||||
);
|
||||
for (final person in people) {
|
||||
await session.db.unsafeExecute(
|
||||
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
|
||||
parameters: QueryParameters.named({'space': person.uuid}),
|
||||
);
|
||||
}
|
||||
people.clear();
|
||||
});
|
||||
|
||||
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
/// Registers a client as Claude does; its client_id.
|
||||
Future<String> register({String method = 'none'}) async {
|
||||
final response = await connector.register(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'client_name': 'Claude',
|
||||
'redirect_uris': [_callback],
|
||||
'grant_types': ['authorization_code', 'refresh_token'],
|
||||
'response_types': ['code'],
|
||||
'token_endpoint_auth_method': method,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(response.status, 201, reason: response.body);
|
||||
return (jsonDecode(response.body) as Map)['client_id'] as String;
|
||||
}
|
||||
|
||||
Map<String, String> request(String clientId) => {
|
||||
'response_type': 'code',
|
||||
'client_id': clientId,
|
||||
'redirect_uri': _callback,
|
||||
'state': 'xyz',
|
||||
'code_challenge': _challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'scope': readScope,
|
||||
'resource': connector.resource,
|
||||
};
|
||||
|
||||
/// Types [code] on the authorization page of [clientId].
|
||||
Future<ConnectorResponse> typeCode(
|
||||
String clientId,
|
||||
String code, {
|
||||
Map<String, String>? fields,
|
||||
}) => connector.authorize(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: Uri(
|
||||
queryParameters: {
|
||||
...fields ?? request(clientId),
|
||||
'connect_code': code,
|
||||
},
|
||||
).query,
|
||||
),
|
||||
);
|
||||
|
||||
/// Connects [person]'s account to [clientId] with a code from their
|
||||
/// app; the authorization code the client gets back.
|
||||
Future<String> connect(UuidValue person, String clientId) async {
|
||||
final code = await endpoints.assistant.connectCode(signedIn(person));
|
||||
final response = await typeCode(clientId, code.code);
|
||||
expect(response.status, 302, reason: response.body);
|
||||
return Uri.parse(
|
||||
response.headers['Location']!,
|
||||
).queryParameters['code']!;
|
||||
}
|
||||
|
||||
Future<ConnectorResponse> exchange(
|
||||
Map<String, String> form, {
|
||||
Map<String, String> headers = const {},
|
||||
}) => connector.token(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...headers,
|
||||
},
|
||||
body: Uri(queryParameters: form).query,
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, String> codeForm(String clientId, String code) => {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': _callback,
|
||||
'client_id': clientId,
|
||||
'code_verifier': _verifier,
|
||||
'resource': connector.resource,
|
||||
};
|
||||
|
||||
/// The tokens [person] gives a newly registered client.
|
||||
Future<Map<String, Object?>> tokensOf(
|
||||
UuidValue person, {
|
||||
String? clientId,
|
||||
}) async {
|
||||
final client = clientId ?? await register();
|
||||
final response = await exchange(
|
||||
codeForm(client, await connect(person, client)),
|
||||
);
|
||||
expect(response.status, 200, reason: response.body);
|
||||
return jsonDecode(response.body) as Map<String, Object?>;
|
||||
}
|
||||
|
||||
Future<ConnectorResponse> mcp(
|
||||
Object? token,
|
||||
Map<String, Object?> message, {
|
||||
String method = 'POST',
|
||||
Map<String, String> headers = const {},
|
||||
}) => answerMcpRequest(
|
||||
session,
|
||||
connectorRequest(
|
||||
method,
|
||||
headers: {
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: jsonEncode(message),
|
||||
),
|
||||
connector,
|
||||
);
|
||||
|
||||
/// The text tool [name] answers with [arguments], for [token].
|
||||
Future<String> call(
|
||||
Object? token,
|
||||
String name, [
|
||||
Map<String, Object?> arguments = const {},
|
||||
]) async {
|
||||
final response = await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'tools/call',
|
||||
'params': {'name': name, 'arguments': arguments},
|
||||
});
|
||||
expect(response.status, 200, reason: response.body);
|
||||
final result = (jsonDecode(response.body) as Map)['result'] as Map;
|
||||
final text = ((result['content'] as List).single as Map)['text'];
|
||||
return result['isError'] == true ? 'ERROR: $text' : text as String;
|
||||
}
|
||||
|
||||
group('Metadata', () {
|
||||
test('names the MCP endpoint and the instance as its authorization '
|
||||
'server', () {
|
||||
final resource = jsonDecode(connector.resourceMetadata().body) as Map;
|
||||
final server = jsonDecode(connector.serverMetadata().body) as Map;
|
||||
|
||||
expect(resource['resource'], 'https://learn.example.org/mcp');
|
||||
expect(resource['authorization_servers'], [
|
||||
'https://learn.example.org',
|
||||
]);
|
||||
expect(server['issuer'], 'https://learn.example.org');
|
||||
expect(server['code_challenge_methods_supported'], ['S256']);
|
||||
expect(
|
||||
server['authorization_response_iss_parameter_supported'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('the instance says where its connector is', () async {
|
||||
final info = await endpoints.instance.info(sessionBuilder);
|
||||
|
||||
expect(info.assistantConnectorUrl, endsWith('/mcp'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Registering', () {
|
||||
test('keeps the name and the redirect URIs, and gives a public '
|
||||
'client no secret', () async {
|
||||
final id = await register();
|
||||
|
||||
final client = await AssistantClient.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.clientId.equals(id),
|
||||
);
|
||||
expect(client!.name, 'Claude');
|
||||
expect(client.redirectUris, [_callback]);
|
||||
expect(client.secretHash, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'refuses plain http but on localhost, and no redirect URIs',
|
||||
() async {
|
||||
Future<int> status(Object? redirects) async =>
|
||||
(await connector.register(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
body: jsonEncode({'redirect_uris': redirects}),
|
||||
),
|
||||
)).status;
|
||||
|
||||
expect(await status(['http://claude.ai/callback']), 400);
|
||||
expect(await status(['https://claude.ai/a#b']), 400);
|
||||
expect(await status(<String>[]), 400);
|
||||
expect(await status(null), 400);
|
||||
expect(await status(['http://localhost:4567/callback']), 201);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('at most 200 clients register in an hour', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
await AssistantClient.db.insert(session, [
|
||||
for (var i = 0; i < maxRegistrationsPerHour; i++)
|
||||
AssistantClient(
|
||||
clientId: 'flood-$i',
|
||||
name: 'Flood',
|
||||
redirectUris: [_callback],
|
||||
createdAt: now,
|
||||
),
|
||||
]);
|
||||
|
||||
final response = await connector.register(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
body: jsonEncode({
|
||||
'redirect_uris': [_callback],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status, 429);
|
||||
});
|
||||
|
||||
group('Single use, also when requests race', () {
|
||||
test('a connect code', () async {
|
||||
final client = await register();
|
||||
final code = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
|
||||
final answers = await Future.wait([
|
||||
typeCode(client, code.code),
|
||||
typeCode(client, code.code),
|
||||
]);
|
||||
|
||||
expect([for (final answer in answers) answer.status]..sort(), [
|
||||
200,
|
||||
302,
|
||||
]);
|
||||
expect(await AssistantGrant.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('an authorization code', () async {
|
||||
final client = await register();
|
||||
final code = await connect(alice, client);
|
||||
|
||||
final answers = await Future.wait([
|
||||
exchange(codeForm(client, code)),
|
||||
exchange(codeForm(client, code)),
|
||||
]);
|
||||
|
||||
expect([for (final answer in answers) answer.status]..sort(), [
|
||||
200,
|
||||
400,
|
||||
]);
|
||||
expect(await AssistantToken.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('a refresh token', () async {
|
||||
final client = await register();
|
||||
final tokens = await tokensOf(alice, clientId: client);
|
||||
final refresh = {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': '${tokens['refresh_token']}',
|
||||
'client_id': client,
|
||||
};
|
||||
|
||||
final answers = await Future.wait([
|
||||
exchange(refresh),
|
||||
exchange(refresh),
|
||||
]);
|
||||
|
||||
expect([for (final answer in answers) answer.status]..sort(), [
|
||||
200,
|
||||
400,
|
||||
]);
|
||||
expect(await AssistantToken.db.count(session), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('Connecting by code', () {
|
||||
test('a code needs a signed-in person', () async {
|
||||
await expectLater(
|
||||
endpoints.assistant.connectCode(sessionBuilder),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a code is eight characters, kept only as a hash, and a new one '
|
||||
'replaces the last', () async {
|
||||
final first = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
final second = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
|
||||
expect(second.code, matches(RegExp(r'^[0-9A-Z]{4}-[0-9A-Z]{4}$')));
|
||||
final kept = await AssistantConnectCode.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(alice),
|
||||
);
|
||||
expect(kept, hasLength(1));
|
||||
expect(kept.single.codeHash, isNot(contains(second.code)));
|
||||
final client = await register();
|
||||
expect((await typeCode(client, first.code)).status, 200);
|
||||
expect((await typeCode(client, second.code)).status, 302);
|
||||
});
|
||||
|
||||
test('the page names the client and where it returns to, and may not '
|
||||
'be framed', () async {
|
||||
final client = await register();
|
||||
|
||||
final page = await connector.authorize(
|
||||
session,
|
||||
connectorRequest('GET', query: request(client)),
|
||||
);
|
||||
|
||||
expect(page.status, 200);
|
||||
expect(page.body, contains('Connect Claude to Halkyon Learn'));
|
||||
expect(page.body, contains('<strong>claude.ai</strong>'));
|
||||
expect(
|
||||
page.headers['Content-Security-Policy'],
|
||||
contains("frame-ancestors 'none'"),
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown client or a redirect URI it didn't register gets a "
|
||||
'page, never a redirect', () async {
|
||||
final client = await register();
|
||||
|
||||
final unknown = await connector.authorize(
|
||||
session,
|
||||
connectorRequest('GET', query: request('nobody')),
|
||||
);
|
||||
final elsewhere = await connector.authorize(
|
||||
session,
|
||||
connectorRequest(
|
||||
'GET',
|
||||
query: {
|
||||
...request(client),
|
||||
'redirect_uri': 'https://evil.example/',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
for (final response in [unknown, elsewhere]) {
|
||||
expect(response.status, 400);
|
||||
expect(response.headers, isNot(contains('Location')));
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'a wrong code connects nothing; the right one, typed loosely, '
|
||||
'connects and sends the person back with a code and the issuer',
|
||||
() async {
|
||||
final client = await register();
|
||||
final code = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
|
||||
final wrong = await typeCode(client, 'AAAA-AAAA');
|
||||
expect(wrong.status, 200);
|
||||
expect(wrong.body, contains("That code doesn't work"));
|
||||
expect(await AssistantConnection.db.count(session), 0);
|
||||
|
||||
final right = await typeCode(
|
||||
client,
|
||||
code.code.toLowerCase().replaceAll('-', ' '),
|
||||
);
|
||||
expect(right.status, 302);
|
||||
final back = Uri.parse(right.headers['Location']!);
|
||||
expect('${back.replace(query: '')}', '$_callback?');
|
||||
expect(back.queryParameters['code'], isNotEmpty);
|
||||
expect(back.queryParameters['state'], 'xyz');
|
||||
expect(back.queryParameters['iss'], 'https://learn.example.org');
|
||||
final connections = await endpoints.assistant.connections(
|
||||
signedIn(alice),
|
||||
);
|
||||
expect(connections.single.name, 'Claude');
|
||||
expect(connections.single.host, 'claude.ai');
|
||||
},
|
||||
);
|
||||
|
||||
test('a code works once, and for ten minutes', () async {
|
||||
final client = await register();
|
||||
final used = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
expect((await typeCode(client, used.code)).status, 302);
|
||||
expect((await typeCode(client, used.code)).status, 200);
|
||||
|
||||
final old = await endpoints.assistant.connectCode(signedIn(bob));
|
||||
final later = await withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||||
() => typeCode(client, old.code),
|
||||
);
|
||||
expect(later.status, 200);
|
||||
});
|
||||
|
||||
test('without PKCE, or for another resource, the client gets an '
|
||||
'error back', () async {
|
||||
final client = await register();
|
||||
final code = await endpoints.assistant.connectCode(signedIn(alice));
|
||||
|
||||
final plain = await typeCode(
|
||||
client,
|
||||
code.code,
|
||||
fields: {...request(client), 'code_challenge_method': 'plain'},
|
||||
);
|
||||
final other = await typeCode(
|
||||
client,
|
||||
code.code,
|
||||
fields: {...request(client), 'resource': 'https://other.example/'},
|
||||
);
|
||||
|
||||
expect(
|
||||
Uri.parse(plain.headers['Location']!).queryParameters['error'],
|
||||
'invalid_request',
|
||||
);
|
||||
expect(
|
||||
Uri.parse(other.headers['Location']!).queryParameters['error'],
|
||||
'invalid_target',
|
||||
);
|
||||
expect(await AssistantConnection.db.count(session), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('Tokens', () {
|
||||
test('a code and its verifier give bearer tokens, kept only as '
|
||||
'hashes', () async {
|
||||
final tokens = await tokensOf(alice);
|
||||
|
||||
expect(tokens['token_type'], 'Bearer');
|
||||
expect(tokens['expires_in'], 3600);
|
||||
expect(tokens['scope'], readScope);
|
||||
final kept = await AssistantToken.db.find(session);
|
||||
expect(kept, hasLength(1));
|
||||
expect(kept.single.accessHash, isNot(tokens['access_token']));
|
||||
expect(kept.single.resource, connector.resource);
|
||||
});
|
||||
|
||||
test('a wrong verifier spends the code', () async {
|
||||
final client = await register();
|
||||
final code = await connect(alice, client);
|
||||
|
||||
final wrong = await exchange({
|
||||
...codeForm(client, code),
|
||||
'code_verifier': 'x' * 43,
|
||||
});
|
||||
final again = await exchange(codeForm(client, code));
|
||||
|
||||
expect(wrong.status, 400);
|
||||
expect((jsonDecode(wrong.body) as Map)['error'], 'invalid_grant');
|
||||
expect(again.status, 400);
|
||||
});
|
||||
|
||||
test("another client can't use the code", () async {
|
||||
final client = await register();
|
||||
final other = await register();
|
||||
final code = await connect(alice, client);
|
||||
|
||||
final response = await exchange(codeForm(other, code));
|
||||
|
||||
expect(response.status, 400);
|
||||
});
|
||||
|
||||
test('a refresh gives a new pair and spends the old refresh '
|
||||
'token', () async {
|
||||
final client = await register();
|
||||
final first = await tokensOf(alice, clientId: client);
|
||||
final refresh = {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': '${first['refresh_token']}',
|
||||
'client_id': client,
|
||||
};
|
||||
|
||||
final renewed = await exchange(refresh);
|
||||
final reused = await exchange(refresh);
|
||||
|
||||
expect(renewed.status, 200);
|
||||
final second = jsonDecode(renewed.body) as Map;
|
||||
expect(second['access_token'], isNot(first['access_token']));
|
||||
expect(reused.status, 400);
|
||||
expect(await call(second['access_token'], 'list_studysets'), '[]');
|
||||
expect(
|
||||
(await mcp(first['access_token'], {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'ping',
|
||||
})).status,
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
test('a client registered with a secret needs it, in the form or '
|
||||
'as Basic authorization', () async {
|
||||
final response = await connector.register(
|
||||
session,
|
||||
connectorRequest(
|
||||
'POST',
|
||||
body: jsonEncode({
|
||||
'client_name': 'Claude',
|
||||
'redirect_uris': [_callback],
|
||||
'token_endpoint_auth_method': 'client_secret_basic',
|
||||
}),
|
||||
),
|
||||
);
|
||||
final registered = jsonDecode(response.body) as Map;
|
||||
final id = registered['client_id'] as String;
|
||||
final secret = registered['client_secret'] as String;
|
||||
|
||||
final without = await exchange(
|
||||
codeForm(id, await connect(alice, id)),
|
||||
);
|
||||
final basic = await exchange(
|
||||
codeForm(id, await connect(alice, id)),
|
||||
headers: {
|
||||
'Authorization':
|
||||
'Basic ${base64.encode(utf8.encode('$id:$secret'))}',
|
||||
},
|
||||
);
|
||||
|
||||
expect(without.status, 401);
|
||||
expect(basic.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
group('The MCP endpoint', () {
|
||||
/// Writes as [person] through the sync engine, as their device would.
|
||||
Future<T> asPerson<T>(
|
||||
UuidValue person,
|
||||
Future<T> Function(Transaction transaction) write,
|
||||
) => _syncDatabases[session]!.transactionForUser(person, write);
|
||||
|
||||
/// Gives [person] a studyset [title] holding cards with [fronts],
|
||||
/// each card's back the front's English, a note, and a review of
|
||||
/// the first card, deleted at [deletedAt] if given; its id.
|
||||
Future<UuidValue> studyset(
|
||||
UuidValue person,
|
||||
String title, {
|
||||
List<(String, String)> cards = const [],
|
||||
UuidValue? parent,
|
||||
DateTime? deletedAt,
|
||||
}) => asPerson(person, (transaction) async {
|
||||
final id = (await Studyset.db.insertRow(
|
||||
session,
|
||||
Studyset(
|
||||
parentId: parent,
|
||||
deletedAt: deletedAt,
|
||||
payload: _json({
|
||||
'schemaVersion': 4,
|
||||
'title': title,
|
||||
'description': _rich('About $title'),
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
var position = 0;
|
||||
for (final (front, back) in cards) {
|
||||
final card = (await Card.db.insertRow(
|
||||
session,
|
||||
Card(studysetId: id, position: 'a${position++}'),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
await CardRevision.db.insertRow(
|
||||
session,
|
||||
CardRevision(
|
||||
cardId: card,
|
||||
parents: [],
|
||||
authorId: person,
|
||||
payload: _json({
|
||||
'schemaVersion': 9,
|
||||
'type': 'frontBack',
|
||||
'front': _rich(front),
|
||||
'back': _rich(back),
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
if (position == 1) {
|
||||
await ReviewLog.db.insertRow(
|
||||
session,
|
||||
ReviewLog(
|
||||
cardId: card,
|
||||
rating: 3,
|
||||
reviewedAt: DateTime.now().toUtc(),
|
||||
durationMs: 2000,
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
final note = (await Note.db.insertRow(
|
||||
session,
|
||||
Note(studysetId: id, position: 'a0'),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
await NoteRevision.db.insertRow(
|
||||
session,
|
||||
NoteRevision(
|
||||
noteId: note,
|
||||
parents: [],
|
||||
authorId: person,
|
||||
payload: _json({
|
||||
'schemaVersion': 1,
|
||||
'title': 'Particles',
|
||||
'body': _rich('は marks the topic.'),
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
return id;
|
||||
});
|
||||
|
||||
test('without a valid token, says where to get one', () async {
|
||||
final response = await mcp('nonsense', {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'ping',
|
||||
});
|
||||
|
||||
expect(response.status, 401);
|
||||
expect(
|
||||
response.headers['WWW-Authenticate'],
|
||||
contains(
|
||||
'resource_metadata="https://learn.example.org/.well-known/'
|
||||
'oauth-protected-resource"',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('initializes, takes notifications, and lists five tools, all '
|
||||
'only reading', () async {
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final initialized = await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'initialize',
|
||||
'params': {'protocolVersion': '2025-06-18'},
|
||||
});
|
||||
final notified = await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'notifications/initialized',
|
||||
});
|
||||
final listed = await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 2,
|
||||
'method': 'tools/list',
|
||||
});
|
||||
|
||||
expect(initialized.status, 200);
|
||||
expect(notified.status, 202);
|
||||
final tools =
|
||||
((jsonDecode(listed.body) as Map)['result'] as Map)['tools']
|
||||
as List;
|
||||
expect(
|
||||
[for (final tool in tools) (tool as Map)['name']],
|
||||
[
|
||||
'list_studysets',
|
||||
'read_studyset',
|
||||
'search_cards',
|
||||
'read_note',
|
||||
'study_progress',
|
||||
],
|
||||
);
|
||||
for (final tool in tools) {
|
||||
expect(
|
||||
(tool as Map)['annotations'],
|
||||
containsPair('readOnlyHint', true),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('leaves out a studyset waiting in Recently deleted, and what '
|
||||
'is inside it', () async {
|
||||
final japanese = await studyset(
|
||||
alice,
|
||||
'Japanese',
|
||||
cards: [('火', 'fire')],
|
||||
deletedAt: DateTime.utc(2026, 10, 12),
|
||||
);
|
||||
await studyset(alice, 'Kanji', parent: japanese);
|
||||
await studyset(alice, 'Greek');
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final listed =
|
||||
jsonDecode(await call(token, 'list_studysets')) as List;
|
||||
expect([for (final s in listed) (s as Map)['title']], ['Greek']);
|
||||
final found = jsonDecode(
|
||||
await call(token, 'search_cards', {'query': 'fire'}),
|
||||
) as List;
|
||||
expect(found, isEmpty);
|
||||
});
|
||||
|
||||
test("reads the person's studysets, cards, notes and progress, and "
|
||||
"none of anyone else's", () async {
|
||||
final japanese = await studyset(
|
||||
alice,
|
||||
'Japanese',
|
||||
cards: [('火', 'fire'), ('水', 'water')],
|
||||
);
|
||||
await studyset(alice, 'Kanji', parent: japanese);
|
||||
final secret = await studyset(bob, "Bob's diary");
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final listed =
|
||||
jsonDecode(await call(token, 'list_studysets')) as List;
|
||||
expect(
|
||||
[for (final s in listed) (s as Map)['title']],
|
||||
[
|
||||
'Japanese',
|
||||
'Kanji',
|
||||
],
|
||||
);
|
||||
expect((listed.first as Map)['cards'], 2);
|
||||
expect((listed.last as Map)['inside'], 'Japanese');
|
||||
|
||||
final read = jsonDecode(
|
||||
await call(token, 'read_studyset', {
|
||||
'studyset_id': japanese.uuid,
|
||||
}),
|
||||
) as Map;
|
||||
expect(read['description'], 'About Japanese');
|
||||
expect(
|
||||
[for (final c in read['cards'] as List) (c as Map)['back']],
|
||||
[
|
||||
'fire',
|
||||
'water',
|
||||
],
|
||||
);
|
||||
final note = (read['notes'] as List).single as Map;
|
||||
expect(note['title'], 'Particles');
|
||||
|
||||
final found = jsonDecode(
|
||||
await call(token, 'search_cards', {'query': 'WAT'}),
|
||||
) as List;
|
||||
expect((found.single as Map)['front'], '水');
|
||||
|
||||
final text = jsonDecode(
|
||||
await call(token, 'read_note', {'note_id': note['id']}),
|
||||
) as Map;
|
||||
expect(text['text'], 'は marks the topic.');
|
||||
|
||||
final progress = jsonDecode(
|
||||
await call(token, 'study_progress', {
|
||||
'studyset_id': japanese.uuid,
|
||||
}),
|
||||
) as Map;
|
||||
expect(progress['cards'], 2);
|
||||
expect(progress['cards_studied'], 1);
|
||||
expect(progress['reviews_last_7_days'], 1);
|
||||
|
||||
expect(
|
||||
await call(token, 'read_studyset', {'studyset_id': secret.uuid}),
|
||||
startsWith('ERROR: No studyset'),
|
||||
);
|
||||
});
|
||||
|
||||
test('disconnecting ends the tokens at once', () async {
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
final connection = (await endpoints.assistant.connections(
|
||||
signedIn(alice),
|
||||
)).single;
|
||||
|
||||
await expectLater(
|
||||
endpoints.assistant.disconnect(signedIn(bob), connection.id),
|
||||
throwsA(isA<NotFoundException>()),
|
||||
);
|
||||
await endpoints.assistant.disconnect(signedIn(alice), connection.id);
|
||||
|
||||
expect(
|
||||
(await mcp(token, {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'ping',
|
||||
})).status,
|
||||
401,
|
||||
);
|
||||
expect(await AssistantToken.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('an access token works for an hour', () async {
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final later = await withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 61))),
|
||||
() => mcp(token, {'jsonrpc': '2.0', 'id': 1, 'method': 'ping'}),
|
||||
);
|
||||
|
||||
expect(later.status, 401);
|
||||
});
|
||||
|
||||
test('takes only POST, and no page from a plain-http origin', () async {
|
||||
final token = (await tokensOf(alice))['access_token'];
|
||||
|
||||
final get = await mcp(token, {}, method: 'GET');
|
||||
final origin = await mcp(
|
||||
token,
|
||||
{'jsonrpc': '2.0', 'id': 1, 'method': 'ping'},
|
||||
headers: {'Origin': 'http://evil.example'},
|
||||
);
|
||||
|
||||
expect(get.status, 405);
|
||||
expect(get.headers['Allow'], 'POST');
|
||||
expect(origin.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
test('pruning forgets expired codes and tokens, and clients nobody '
|
||||
'connected within a week', () async {
|
||||
final connected = await register();
|
||||
await tokensOf(alice, clientId: connected);
|
||||
await register();
|
||||
await endpoints.assistant.connectCode(signedIn(bob));
|
||||
|
||||
final forgotten = await forgetExpiredConnectorSecrets(
|
||||
session,
|
||||
now: DateTime.now().toUtc().add(const Duration(days: 31)),
|
||||
);
|
||||
|
||||
expect(forgotten, 3);
|
||||
expect(await AssistantClient.db.count(session), 1);
|
||||
expect(await AssistantConnection.db.count(session), 1);
|
||||
expect(await AssistantConnectCode.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('deleting the account deletes its connections and codes', () async {
|
||||
useLearnBase(sessionBuilder);
|
||||
final carol = (await AuthServices.instance.authUsers.create(session))
|
||||
.id;
|
||||
people.add(carol);
|
||||
await tokensOf(carol);
|
||||
await endpoints.assistant.connectCode(signedIn(carol));
|
||||
|
||||
await deletePerson(session, carol);
|
||||
|
||||
expect(await AssistantConnection.db.count(session), 0);
|
||||
expect(await AssistantToken.db.count(session), 0);
|
||||
expect(await AssistantConnectCode.db.count(session), 0);
|
||||
});
|
||||
},
|
||||
// The sync engine's database, as the running server has it (see
|
||||
// publish_test.dart): the tests delete what they made instead.
|
||||
databaseInterceptor: (session, inner) {
|
||||
final database = offlineSyncDatabaseInterceptor(session, inner);
|
||||
if (database is OfflineSyncDatabase) {
|
||||
_syncDatabases[session] = database;
|
||||
}
|
||||
return database;
|
||||
},
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
);
|
||||
}
|
||||
|
||||
/// The sync engine's database of each session.
|
||||
final _syncDatabases = Expando<OfflineSyncDatabase>();
|
||||
|
||||
/// [json] as a synced payload.
|
||||
ByteData _json(Map<String, Object?> json) =>
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||
|
||||
/// Rich content holding one paragraph of [text], as the app stores it.
|
||||
Map<String, Object?> _rich(String text) => {
|
||||
'formatVersion': 1,
|
||||
'editor': {
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue