feat(privacy): the instance's side of private studysets (ADR 0019)
- `content_key`, a synced table in the person's space, keeps their data keys sealed to their account (a private studyset's, their personal one); `learning_path_answer` gains `keyId` and `nonce` for answers in private studysets. Additive only, on the server and the device. - Publishing refuses a private studyset, and a tree holding a private row (`PublishRefusal.private`); a private studyset elsewhere no longer breaks publishing a readable one. Encrypted tags are named as the device names them (`tagNames`): a name for a tag that isn't the person's, or an encrypted tag of the release without one, refuses it (`tagNamesMismatch`); a readable tag keeps its own name. - The connector leaves private studysets out and says how many there are. - The learner count reads no payload any more: Count me is kept by the instance (`counted_learner`) and the device reports which publications its copies come from (`learning_copy`, accepted only while counted, each report replacing the last); only the reporter's own studysets count. Deleting a person deletes both. - An upload says whether its file is encrypted: only a readable one over 20 MB is checked to be a PDF, and an encrypted one may be 4 KiB larger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
074d05ab4e
commit
a1c5ac3f86
1 changed files with 35261 additions and 198 deletions
|
|
@ -1,4 +1,3 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
|
|
@ -71,50 +70,40 @@ void main() {
|
|||
await PublishedStudyset.db.deleteRow(session, japanese);
|
||||
});
|
||||
|
||||
ByteData json(Map<String, Object?> json) =>
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||
TestSessionBuilder signedIn(UuidValue person, {bool anonymous = false}) =>
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
anonymous ? const {} : {identified},
|
||||
),
|
||||
);
|
||||
|
||||
/// A person (with an identity unless [anonymous]), counted if
|
||||
/// [counted], who studied a copy of Japanese on [daysAgo] (a review
|
||||
/// in [mode] each), and since put it in Recently deleted if
|
||||
/// [deleted].
|
||||
Future<void> learner({
|
||||
/// [counted], who studied a private copy of Japanese on [daysAgo] (a
|
||||
/// review in [mode] each), which their device [reported], and since
|
||||
/// put it in Recently deleted if [deleted]. Returns the copy.
|
||||
Future<UuidValue> learner({
|
||||
bool counted = true,
|
||||
bool anonymous = false,
|
||||
List<int> daysAgo = const [1, 2, 3],
|
||||
String mode = 'review',
|
||||
bool deleted = false,
|
||||
bool reported = true,
|
||||
}) async {
|
||||
final person = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: anonymous ? const {} : {identified},
|
||||
)).id;
|
||||
people.add(person);
|
||||
await asPerson(session, person, (transaction) async {
|
||||
await PersonSettings.db.insertRow(
|
||||
session,
|
||||
PersonSettings(
|
||||
payload: json({
|
||||
'schemaVersion': 8,
|
||||
'studyTags': <String, Object?>{},
|
||||
'countLearning': ?(counted ? true : null),
|
||||
}),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
final studyset = await asPerson(session, person, (transaction) async {
|
||||
// A private copy: its origin is encrypted (ADR 0019).
|
||||
final studyset = (await Studyset.db.insertRow(
|
||||
session,
|
||||
Studyset(
|
||||
deletedAt: deleted ? now : null,
|
||||
payload: json({
|
||||
'schemaVersion': 5,
|
||||
'title': 'Japanese',
|
||||
'published': {
|
||||
'publication': japanese.publicId.uuid,
|
||||
'item': 'root',
|
||||
'version': 1,
|
||||
},
|
||||
}),
|
||||
keyId: 'studyset-key',
|
||||
nonce: ByteData(24),
|
||||
payload: ByteData.sublistView(Uint8List.fromList([0xff, 1, 2])),
|
||||
),
|
||||
transaction: transaction,
|
||||
)).id!;
|
||||
|
|
@ -136,7 +125,35 @@ void main() {
|
|||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
return studyset;
|
||||
});
|
||||
if (counted && !anonymous) {
|
||||
await endpoints.learningCount.setCounted(signedIn(person), true);
|
||||
} else if (counted) {
|
||||
// Only the endpoint is refused an anonymous person; their row is
|
||||
// made as a modified server would, and still never counts.
|
||||
await CountedLearner.db.insertRow(
|
||||
session,
|
||||
CountedLearner(authUserId: person, since: now),
|
||||
);
|
||||
await LearningCopy.db.insertRow(
|
||||
session,
|
||||
LearningCopy(
|
||||
authUserId: person,
|
||||
publicationId: japanese.publicId.uuid,
|
||||
studysetId: studyset,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (reported && !anonymous) {
|
||||
await endpoints.learningCount.reportCopies(signedIn(person), [
|
||||
CopyReport(
|
||||
publicationId: japanese.publicId.uuid,
|
||||
studysetIds: [studyset],
|
||||
),
|
||||
]);
|
||||
}
|
||||
return studyset;
|
||||
}
|
||||
|
||||
Future<int> counted() async {
|
||||
|
|
@ -148,7 +165,7 @@ void main() {
|
|||
}
|
||||
|
||||
test('counts the people with an identity who chose to be, studying a '
|
||||
'copy on 3 days of the last 30', () async {
|
||||
'copy their device reported on 3 days of the last 30', () async {
|
||||
await learner();
|
||||
await learner(daysAgo: [1, 1, 1, 2, 29]);
|
||||
|
||||
|
|
@ -156,24 +173,108 @@ void main() {
|
|||
});
|
||||
|
||||
test('leaves out who chose not to be counted, anonymous people, too '
|
||||
'little or too long ago, and a copy deleted', () async {
|
||||
'little or too long ago, a copy deleted, and one never '
|
||||
'reported', () async {
|
||||
await learner(counted: false);
|
||||
await learner(anonymous: true);
|
||||
await learner(daysAgo: [1, 2]);
|
||||
await learner(daysAgo: [31, 32, 40]);
|
||||
await learner(mode: 'reset');
|
||||
await learner(deleted: true);
|
||||
await learner(reported: false);
|
||||
|
||||
expect(await counted(), 0);
|
||||
});
|
||||
|
||||
test('a count goes down when learning stops', () async {
|
||||
test("a studyset reported that isn't the reporter's own never "
|
||||
'counts', () async {
|
||||
final alices = await learner(reported: false);
|
||||
await learner(daysAgo: const []);
|
||||
final mallory = people.last;
|
||||
|
||||
await endpoints.learningCount.reportCopies(signedIn(mallory), [
|
||||
CopyReport(
|
||||
publicationId: japanese.publicId.uuid,
|
||||
studysetIds: [alices],
|
||||
),
|
||||
]);
|
||||
|
||||
expect(await counted(), 0);
|
||||
});
|
||||
|
||||
test('a count goes down when learning stops, or the person no longer '
|
||||
'wants to be counted', () async {
|
||||
await learner();
|
||||
expect(await counted(), 1);
|
||||
|
||||
await deletePerson(session, people.removeLast());
|
||||
|
||||
await endpoints.learningCount.setCounted(
|
||||
signedIn(people.last),
|
||||
false,
|
||||
);
|
||||
expect(await counted(), 0);
|
||||
expect(
|
||||
await LearningCopy.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(people.last),
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
await learner();
|
||||
expect(await counted(), 1);
|
||||
await deletePerson(session, people.removeLast());
|
||||
expect(await counted(), 0);
|
||||
});
|
||||
|
||||
test('reports are kept only while the person chose to be counted, '
|
||||
'each replacing the last', () async {
|
||||
final copy = await learner(counted: false);
|
||||
final person = people.last;
|
||||
final report = [
|
||||
CopyReport(
|
||||
publicationId: japanese.publicId.uuid,
|
||||
studysetIds: [copy],
|
||||
),
|
||||
];
|
||||
|
||||
expect(
|
||||
await endpoints.learningCount.reportCopies(signedIn(person), report),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
await endpoints.learningCount.counted(signedIn(person)),
|
||||
isFalse,
|
||||
);
|
||||
await endpoints.learningCount.setCounted(signedIn(person), true);
|
||||
expect(await endpoints.learningCount.counted(signedIn(person)), isTrue);
|
||||
expect(
|
||||
await endpoints.learningCount.reportCopies(signedIn(person), report),
|
||||
isTrue,
|
||||
);
|
||||
expect(await counted(), 1);
|
||||
|
||||
await endpoints.learningCount.reportCopies(signedIn(person), []);
|
||||
expect(await counted(), 0);
|
||||
});
|
||||
|
||||
test('anonymous people are refused', () async {
|
||||
final person = const Uuid().v7obj();
|
||||
|
||||
await expectLater(
|
||||
endpoints.learningCount.setCounted(
|
||||
signedIn(person, anonymous: true),
|
||||
true,
|
||||
),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.learningCount.reportCopies(
|
||||
signedIn(person, anonymous: true),
|
||||
[],
|
||||
),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
expect(await CountedLearner.db.count(session), 0);
|
||||
});
|
||||
},
|
||||
databaseInterceptor: syncDatabaseInterceptor,
|
||||
|
|
|
|||
Loading…
Reference in a new issue