fix(privacy): the instance's side of the reading of private studysets (ADR 0019)

An upload said to be encrypted must start as a sealed file does (HLKF,
version 1), so a readable file can't skip the PDF check by saying so. A
person's room counts the files a live asset row names and those claimed
within the week of grace, so a private studyset's rewrite near the limit
no longer fails for a week while pruning keeps the replaced files. Copy
reports are limited to 60 an hour per person. Tests: what goes with the
person, `counted` refused to anonymous people.

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-22 08:45:01 +02:00
commit d9b5e06618
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 204 additions and 13 deletions

View file

@ -191,7 +191,9 @@ void main() {
'look into it, a little larger than 50 MB too, and no larger',
() async {
const size = AssetEndpoint.maxOtherBytes + 1;
final sealed = Uint8List(size)..fillRange(0, size, 7);
final sealed = Uint8List(size)
..fillRange(0, size, 7)
..setAll(0, [...utf8.encode('HLKF'), 1]);
final readable = Uint8List(size)..fillRange(0, size, 8);
const most =
AssetEndpoint.maxBytes + AssetEndpoint.encryptionOverhead;
@ -228,6 +230,25 @@ void main() {
},
);
test(
'keeps no file said to be encrypted that is no sealed file, as a '
'readable one would skip the PDF check',
() async {
const size = AssetEndpoint.maxOtherBytes + 1;
final readable = Uint8List(size)..fillRange(0, size, 9);
final wrongVersion = Uint8List(size)
..fillRange(0, size, 9)
..setAll(0, [...utf8.encode('HLKF'), 2]);
expect(await add(alice, readable, encrypted: true), isFalse);
expect(await add(alice, wrongVersion, encrypted: true), isFalse);
expect(
await add(alice, utf8.encode('HLKF'), encrypted: true),
isFalse,
);
},
);
test('finishes no upload that never came', () async {
expect(
await endpoints.asset.finishUpload(signedIn(alice), imageHash),
@ -488,6 +509,40 @@ void main() {
);
});
test(
'counts no file a live row no longer names once its week of grace '
'is over, as one a rewrite replaced (ADR 0019)',
() async {
final replaced = List.filled(6, 3);
final named = List.filled(3, 4);
String hash(List<int> bytes) => sha256.convert(bytes).toString();
final gone = await _assetIn(session, alice, hash(replaced));
await add(alice, replaced);
await _deleteAsset(session, alice, gone);
await _assetIn(session, alice, hash(named));
await add(alice, named);
final old = DateTime.now().toUtc().subtract(
const Duration(days: 8),
);
await _claimed(session, alice, hash(replaced), old);
await _claimed(session, alice, hash(named), old);
final usage = await endpoints.asset.usage(signedIn(alice));
expect(usage.usedBytes, 3);
},
);
test(
'counts a file no row names yet while it is recent, so uploads '
"without rows can't fill the room unnoticed",
() async {
await add(alice, List.filled(4, 5));
final usage = await endpoints.asset.usage(signedIn(alice));
expect(usage.usedBytes, 4);
},
);
test('tells how much room the files take, and may take', () async {
await add(alice, List.filled(4, 1));

View file

@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/explore/learner_count.dart';
import 'package:halkyon_learn_server/src/explore/learning_count_endpoint.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -222,8 +223,51 @@ void main() {
await learner();
expect(await counted(), 1);
final gone = people.last;
await deletePerson(session, people.removeLast());
expect(await counted(), 0);
// Nothing of theirs stays behind, whatever the count says.
expect(
await CountedLearner.db.count(
session,
where: (t) => t.authUserId.equals(gone),
),
0,
);
expect(
await LearningCopy.db.count(
session,
where: (t) => t.authUserId.equals(gone),
),
0,
);
});
test('a person reports at most an hour\'s worth of times', () async {
final copy = await learner();
final person = people.last;
final report = [
CopyReport(
publicationId: japanese.publicId.uuid,
studysetIds: [copy],
),
];
// The learner reported once already.
for (var i = 1; i < copyReportsPerHour; i++) {
expect(
await endpoints.learningCount.reportCopies(
signedIn(person),
report,
),
isTrue,
);
}
expect(
await endpoints.learningCount.reportCopies(signedIn(person), []),
isFalse,
);
expect(await counted(), 1);
});
test('reports are kept only while the person chose to be counted, '
@ -274,6 +318,10 @@ void main() {
),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
await expectLater(
endpoints.learningCount.counted(signedIn(person, anonymous: true)),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
expect(await CountedLearner.db.count(session), 0);
});
},