459 lines
14 KiB
Dart
459 lines
14 KiB
Dart
|
|
import 'dart:convert';
|
||
|
|
import 'dart:typed_data';
|
||
|
|
|
||
|
|
import 'package:crypto/crypto.dart';
|
||
|
|
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||
|
|
import 'package:halkyon_learn_server/src/asset/file_pruning.dart';
|
||
|
|
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||
|
|
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
|
||
|
|
import 'package:serverpod/serverpod.dart';
|
||
|
|
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||
|
|
import 'package:test/test.dart';
|
||
|
|
|
||
|
|
import '../test_tools/serverpod_test_tools.dart';
|
||
|
|
|
||
|
|
void main() {
|
||
|
|
withServerpod(
|
||
|
|
'Files',
|
||
|
|
(sessionBuilder, endpoints) {
|
||
|
|
late Session session;
|
||
|
|
late UuidValue alice;
|
||
|
|
late UuidValue bob;
|
||
|
|
final image = utf8.encode('an image');
|
||
|
|
final imageHash = sha256.convert(image).toString();
|
||
|
|
|
||
|
|
/// The spaces a test made, which it deletes again with their files.
|
||
|
|
final spaces = <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);
|
||
|
|
session = sessionBuilder.build();
|
||
|
|
alice = const Uuid().v7obj();
|
||
|
|
bob = const Uuid().v7obj();
|
||
|
|
spaces.addAll([alice, bob]);
|
||
|
|
});
|
||
|
|
|
||
|
|
tearDown(() async {
|
||
|
|
for (final space in spaces) {
|
||
|
|
await session.db.unsafeExecute(
|
||
|
|
'DELETE FROM "serverpod_cloud_storage" WHERE "path" LIKE @prefix',
|
||
|
|
parameters: QueryParameters.named({
|
||
|
|
'prefix': '${assetPath(space, '')}%',
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
await session.db.unsafeExecute(
|
||
|
|
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
|
||
|
|
parameters: QueryParameters.named({'space': space.uuid}),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
spaces.clear();
|
||
|
|
});
|
||
|
|
|
||
|
|
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
|
||
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
||
|
|
person.uuid,
|
||
|
|
{},
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
/// Adds [bytes] as [person], as the app does: asks how to upload them,
|
||
|
|
/// uploads them unless the server has them, and finishes the upload.
|
||
|
|
Future<bool> add(UuidValue person, List<int> bytes, {String? as}) async {
|
||
|
|
final hash = as ?? sha256.convert(bytes).toString();
|
||
|
|
final description = await endpoints.asset.startUpload(
|
||
|
|
signedIn(person),
|
||
|
|
hash,
|
||
|
|
bytes.length,
|
||
|
|
);
|
||
|
|
if (description != null) {
|
||
|
|
await _upload(session, description, bytes);
|
||
|
|
}
|
||
|
|
return await endpoints.asset.finishUpload(signedIn(person), hash);
|
||
|
|
}
|
||
|
|
|
||
|
|
test('refuses callers who are not signed in', () async {
|
||
|
|
final nobody = sessionBuilder.copyWith(
|
||
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
||
|
|
);
|
||
|
|
final unauthenticated = throwsA(
|
||
|
|
isA<ServerpodUnauthenticatedException>(),
|
||
|
|
);
|
||
|
|
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.startUpload(nobody, imageHash, image.length),
|
||
|
|
unauthenticated,
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.finishUpload(nobody, imageHash),
|
||
|
|
unauthenticated,
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.download(nobody, const Uuid().v7obj()),
|
||
|
|
unauthenticated,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('stores a file once and serves it by its asset row', () async {
|
||
|
|
final asset = await _assetIn(session, alice, imageHash);
|
||
|
|
|
||
|
|
expect(await add(alice, image), isTrue);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
await endpoints.asset.startUpload(
|
||
|
|
signedIn(alice),
|
||
|
|
imageHash,
|
||
|
|
image.length,
|
||
|
|
),
|
||
|
|
isNull,
|
||
|
|
);
|
||
|
|
expect(
|
||
|
|
Uint8List.sublistView(
|
||
|
|
await endpoints.asset.download(signedIn(alice), asset),
|
||
|
|
),
|
||
|
|
image,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('keeps no file whose content has another hash', () async {
|
||
|
|
final asset = await _assetIn(session, alice, imageHash);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
await add(alice, utf8.encode('not that image'), as: imageHash),
|
||
|
|
isFalse,
|
||
|
|
);
|
||
|
|
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.download(signedIn(alice), asset),
|
||
|
|
_notFound,
|
||
|
|
);
|
||
|
|
expect(
|
||
|
|
await endpoints.asset.startUpload(
|
||
|
|
signedIn(alice),
|
||
|
|
imageHash,
|
||
|
|
image.length,
|
||
|
|
),
|
||
|
|
isNotNull,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('finishes no upload that never came', () async {
|
||
|
|
expect(
|
||
|
|
await endpoints.asset.finishUpload(signedIn(alice), imageHash),
|
||
|
|
isFalse,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('refuses what is no SHA-256, and sizes out of range', () async {
|
||
|
|
for (final hash in [
|
||
|
|
'',
|
||
|
|
'abc',
|
||
|
|
imageHash.toUpperCase(),
|
||
|
|
'$imageHash 0',
|
||
|
|
]) {
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.startUpload(signedIn(alice), hash, 1),
|
||
|
|
throwsArgumentError,
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.finishUpload(signedIn(alice), hash),
|
||
|
|
throwsArgumentError,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
for (final size in [0, -1, AssetEndpoint.maxBytes + 1]) {
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.startUpload(signedIn(alice), imageHash, size),
|
||
|
|
throwsArgumentError,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test(
|
||
|
|
"serves nobody else's file, exactly like a missing one",
|
||
|
|
() async {
|
||
|
|
final asset = await _assetIn(session, alice, imageHash);
|
||
|
|
await add(alice, image);
|
||
|
|
final notUploaded = await _assetIn(
|
||
|
|
session,
|
||
|
|
alice,
|
||
|
|
sha256.convert([1]).toString(),
|
||
|
|
);
|
||
|
|
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.download(signedIn(bob), asset),
|
||
|
|
_notFound,
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.download(signedIn(alice), notUploaded),
|
||
|
|
_notFound,
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.download(signedIn(alice), const Uuid().v7obj()),
|
||
|
|
_notFound,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
test(
|
||
|
|
"keeps every person's files apart: the same file from someone else "
|
||
|
|
'neither shows nor replaces yours',
|
||
|
|
() async {
|
||
|
|
final asset = await _assetIn(session, alice, imageHash);
|
||
|
|
await add(alice, image);
|
||
|
|
|
||
|
|
// Bob is asked for the file as if nobody had it.
|
||
|
|
expect(
|
||
|
|
await endpoints.asset.startUpload(
|
||
|
|
signedIn(bob),
|
||
|
|
imageHash,
|
||
|
|
image.length,
|
||
|
|
),
|
||
|
|
isNotNull,
|
||
|
|
);
|
||
|
|
expect(
|
||
|
|
await add(bob, utf8.encode('not that image'), as: imageHash),
|
||
|
|
isFalse,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
Uint8List.sublistView(
|
||
|
|
await endpoints.asset.download(signedIn(alice), asset),
|
||
|
|
),
|
||
|
|
image,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
test(
|
||
|
|
"serves a shared space's files to its members, and only them",
|
||
|
|
() async {
|
||
|
|
final carol = const Uuid().v7obj();
|
||
|
|
final spaces = OfflineSyncSpaces(session);
|
||
|
|
final shared = await spaces.createFor(carol);
|
||
|
|
await spaces.grant(
|
||
|
|
user: alice,
|
||
|
|
space: shared,
|
||
|
|
role: OfflineSyncSpaceRole.readOnly,
|
||
|
|
);
|
||
|
|
final asset = await _assetIn(
|
||
|
|
session,
|
||
|
|
carol,
|
||
|
|
imageHash,
|
||
|
|
space: shared,
|
||
|
|
);
|
||
|
|
await session.storage.storeFile(
|
||
|
|
storageId: assetStorage,
|
||
|
|
path: assetPath(shared, imageHash),
|
||
|
|
byteData: ByteData.sublistView(image),
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
Uint8List.sublistView(
|
||
|
|
await endpoints.asset.download(signedIn(alice), asset),
|
||
|
|
),
|
||
|
|
image,
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.download(signedIn(bob), asset),
|
||
|
|
_notFound,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
test('serves no deleted asset', () async {
|
||
|
|
final asset = await _assetIn(session, alice, imageHash);
|
||
|
|
await add(alice, image);
|
||
|
|
|
||
|
|
await _deleteAsset(session, alice, asset);
|
||
|
|
|
||
|
|
await expectLater(
|
||
|
|
endpoints.asset.download(signedIn(alice), asset),
|
||
|
|
_notFound,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
group('pruning', () {
|
||
|
|
final now = DateTime.now().toUtc();
|
||
|
|
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||
|
|
|
||
|
|
Future<bool> stored(UuidValue space, String hash) async =>
|
||
|
|
await CloudStorageEntry.db.count(
|
||
|
|
session,
|
||
|
|
where: (t) =>
|
||
|
|
t.storageId.equals(assetStorage) &
|
||
|
|
t.path.equals(assetPath(space, hash)),
|
||
|
|
) >
|
||
|
|
0;
|
||
|
|
|
||
|
|
test(
|
||
|
|
'deletes the files no asset refers to any more, a week after they '
|
||
|
|
'were last claimed',
|
||
|
|
() async {
|
||
|
|
final kept = utf8.encode('kept');
|
||
|
|
final deleted = utf8.encode('deleted');
|
||
|
|
final recent = utf8.encode('recently uploaded');
|
||
|
|
final unfinished = utf8.encode('never finished');
|
||
|
|
final bobs = utf8.encode("bob's");
|
||
|
|
String hash(List<int> bytes) => sha256.convert(bytes).toString();
|
||
|
|
// Whatever an earlier, aborted run left behind goes first.
|
||
|
|
await pruneUnreferencedFiles(session, now: now);
|
||
|
|
|
||
|
|
await _assetIn(session, alice, hash(kept));
|
||
|
|
await add(alice, kept);
|
||
|
|
final gone = await _assetIn(session, alice, hash(deleted));
|
||
|
|
await add(alice, deleted);
|
||
|
|
await _deleteAsset(session, alice, gone);
|
||
|
|
await add(alice, recent);
|
||
|
|
await _upload(
|
||
|
|
session,
|
||
|
|
(await endpoints.asset.startUpload(
|
||
|
|
signedIn(alice),
|
||
|
|
hash(unfinished),
|
||
|
|
unfinished.length,
|
||
|
|
))!,
|
||
|
|
unfinished,
|
||
|
|
);
|
||
|
|
await _assetIn(session, alice, hash(unfinished));
|
||
|
|
await add(bob, bobs);
|
||
|
|
for (final (space, bytes) in [
|
||
|
|
(alice, kept),
|
||
|
|
(alice, deleted),
|
||
|
|
(alice, unfinished),
|
||
|
|
(bob, bobs),
|
||
|
|
]) {
|
||
|
|
await _claimed(session, space, hash(bytes), daysAgo(8));
|
||
|
|
}
|
||
|
|
await _claimed(session, alice, hash(recent), daysAgo(6));
|
||
|
|
|
||
|
|
expect(await pruneUnreferencedFiles(session, now: now), 3);
|
||
|
|
|
||
|
|
expect(await stored(alice, hash(kept)), isTrue);
|
||
|
|
expect(await stored(alice, hash(recent)), isTrue);
|
||
|
|
expect(await stored(alice, hash(deleted)), isFalse);
|
||
|
|
expect(await stored(alice, hash(unfinished)), isFalse);
|
||
|
|
expect(await stored(bob, hash(bobs)), isFalse);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
test('keeps a file asked for again', () async {
|
||
|
|
await add(alice, image);
|
||
|
|
await _claimed(session, alice, imageHash, daysAgo(30));
|
||
|
|
|
||
|
|
expect(
|
||
|
|
await endpoints.asset.startUpload(
|
||
|
|
signedIn(alice),
|
||
|
|
imageHash,
|
||
|
|
image.length,
|
||
|
|
),
|
||
|
|
isNull,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(await pruneUnreferencedFiles(session, now: now), 0);
|
||
|
|
expect(await stored(alice, imageHash), isTrue);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
},
|
||
|
|
// The sync engine's database, as the running server has it: deleted rows
|
||
|
|
// are tombstones it hides. Writing through it takes transactions of its
|
||
|
|
// own, outside the one a test would be rolled back in, so 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, as the test pod's own
|
||
|
|
/// database wraps it.
|
||
|
|
final _syncDatabases = Expando<OfflineSyncDatabase>();
|
||
|
|
|
||
|
|
final _notFound = throwsA(
|
||
|
|
isA<NotFoundException>().having((e) => e.what, 'what', 'file'),
|
||
|
|
);
|
||
|
|
|
||
|
|
/// An asset row for the file with [hash], written as [person] would through
|
||
|
|
/// the sync engine, in their personal space or in [space]; returns its id.
|
||
|
|
Future<UuidValue> _assetIn(
|
||
|
|
Session session,
|
||
|
|
UuidValue person,
|
||
|
|
String hash, {
|
||
|
|
UuidValue? space,
|
||
|
|
}) => _syncDatabases[session]!.transactionForUser(
|
||
|
|
person,
|
||
|
|
spaceId: space,
|
||
|
|
(transaction) async => (await Asset.db.insertRow(
|
||
|
|
session,
|
||
|
|
Asset(
|
||
|
|
sha256: hash,
|
||
|
|
mimeType: 'image/png',
|
||
|
|
byteSize: 1,
|
||
|
|
payload: ByteData(0),
|
||
|
|
),
|
||
|
|
transaction: transaction,
|
||
|
|
)).id!,
|
||
|
|
);
|
||
|
|
|
||
|
|
/// Deletes the asset row [asset] as [person] would through the sync engine.
|
||
|
|
Future<void> _deleteAsset(
|
||
|
|
Session session,
|
||
|
|
UuidValue person,
|
||
|
|
UuidValue asset,
|
||
|
|
) => _syncDatabases[session]!.transactionForUser(
|
||
|
|
person,
|
||
|
|
(transaction) => Asset.db.deleteWhere(
|
||
|
|
session,
|
||
|
|
where: (t) => t.id.equals(asset),
|
||
|
|
transaction: transaction,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
/// Uploads [bytes] as the upload [description] says, standing in for the
|
||
|
|
/// app's HTTP upload: the server's upload route stores the file unverified at
|
||
|
|
/// the path the description names, if its key is the one issued.
|
||
|
|
Future<void> _upload(
|
||
|
|
Session session,
|
||
|
|
String description,
|
||
|
|
List<int> bytes,
|
||
|
|
) async {
|
||
|
|
final url = Uri.parse(
|
||
|
|
(jsonDecode(description) as Map<String, Object?>)['url']! as String,
|
||
|
|
);
|
||
|
|
final storageId = url.queryParameters['storage']!;
|
||
|
|
final path = url.queryParameters['path']!;
|
||
|
|
final issued = await session.db.unsafeQuery(
|
||
|
|
'SELECT 1 FROM "serverpod_cloud_storage_direct_upload" '
|
||
|
|
'WHERE "storageId" = @storage AND "path" = @path AND "authKey" = @key',
|
||
|
|
parameters: QueryParameters.named({
|
||
|
|
'storage': storageId,
|
||
|
|
'path': path,
|
||
|
|
'key': url.queryParameters['key'],
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
expect(issued, isNotEmpty, reason: 'the upload was authorized');
|
||
|
|
final storage =
|
||
|
|
Serverpod.instance.storage[storageId]! as DatabaseCloudStorage;
|
||
|
|
await storage.storeUnverifiedFile(
|
||
|
|
session: session,
|
||
|
|
path: path,
|
||
|
|
byteData: ByteData.sublistView(Uint8List.fromList(bytes)),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Makes the stored file with [hash] of [space] last claimed [at].
|
||
|
|
Future<void> _claimed(
|
||
|
|
Session session,
|
||
|
|
UuidValue space,
|
||
|
|
String hash,
|
||
|
|
DateTime at,
|
||
|
|
) => CloudStorageEntry.db.updateWhere(
|
||
|
|
session,
|
||
|
|
columnValues: (t) => [t.addedTime(at)],
|
||
|
|
where: (t) =>
|
||
|
|
t.storageId.equals(assetStorage) & t.path.equals(assetPath(space, hash)),
|
||
|
|
);
|