import 'dart:convert'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:halkyon_base_server/base.dart'; import 'package:serverpod/protocol.dart' show CloudStorageEntry; import 'package:serverpod/serverpod.dart'; import 'package:test/test.dart'; import '../support/fixtures.dart'; import '../test_tools/serverpod_test_tools.dart'; /// A host's row naming a file: the space it is in, the file's hash, and who /// may read it (its space's members). typedef _Row = ({UuidValue space, String sha256, Set readers}); void main() { withServerpod('Files (ADR 0043)', (sessionBuilder, endpoints) { late Session session; late UuidValue alice; late UuidValue bob; /// The host's live rows, by their ID. late Map rows; final image = utf8.encode('an image'); final imageHash = sha256.convert(image).toString(); /// The file store a host gives, over [rows], taking what [accepts] does. FileStore store({AcceptsFile accepts = FileStore.acceptsAny}) => FileStore( fileOfRow: (session, rowId) async { final row = rows[rowId]; final caller = UuidValue.withValidation( session.authenticated!.userIdentifier, ); return row == null || !row.readers.contains(caller) ? null : (space: row.space, sha256: row.sha256); }, liveHashes: (session, space, hashes) async => { for (final row in rows.values) if (row.space == space && (hashes?.contains(row.sha256) ?? true)) row.sha256, }, maxBytes: 64, accepts: accepts, ); setUp(() { useBase(sessionBuilder, files: store()); session = sessionBuilder.build(); alice = const Uuid().v7obj(); bob = const Uuid().v7obj(); rows = {}; }); tearDown(() async { fileLimits = null; await FileAllowance.db.deleteWhere( session, where: (t) => t.authUserId.inSet({alice, bob}), ); await session.db.unsafeExecute( 'DELETE FROM "serverpod_cloud_storage" WHERE "path" LIKE \'spaces/%\'', ); }); /// [person]'s session, `identified`. TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo(person.uuid, { identified, }), ); /// Adds [bytes] as [person], as an app does. Future add( UuidValue person, List bytes, { String? as, bool? encrypted, }) async { final hash = as ?? sha256.convert(bytes).toString(); final description = await endpoints.files.startUpload( signedIn(person), hash, bytes.length, encrypted: encrypted, ); if (description != null) { await _upload(session, description, bytes); } return await endpoints.files.finishUpload( signedIn(person), hash, encrypted: encrypted, ); } /// A row of [space] naming [hash], which [readers] may read. UuidValue row(UuidValue space, String hash, Set readers) { final id = const Uuid().v7obj(); rows[id] = (space: space, sha256: hash, readers: readers); return id; } test('refuses callers who are not signed in', () async { final nobody = sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ); await expectLater( endpoints.files.startUpload(nobody, imageHash, image.length), throwsA(isA()), ); await expectLater( endpoints.files.download(nobody, const Uuid().v7obj()), throwsA(isA()), ); }); test('a host without a file store keeps no files', () async { useBase(sessionBuilder); await expectLater( endpoints.files.startUpload(signedIn(alice), imageHash, image.length), throwsA(isA()), ); await expectLater( endpoints.files.usage(signedIn(alice)), throwsA(isA()), ); }); test("stores a file in the caller's space once, and serves it by a row " 'its reader may read', () async { expect(await add(alice, image), isTrue); expect( await endpoints.files.startUpload( signedIn(alice), imageHash, image.length, ), isNull, reason: 'it is there', ); final id = row(alice, imageHash, {alice, bob}); expect( Uint8List.sublistView( await endpoints.files.download(signedIn(bob), id), ), image, ); }); test("serves nothing of a row the caller can't read, an unknown row, or " 'a file not uploaded, alike', () async { await add(alice, image); final hers = row(alice, imageHash, {alice}); final missing = row(alice, sha256.convert([1]).toString(), {alice}); for (final (person, id) in [ (bob, hers), (alice, const Uuid().v7obj()), (alice, missing), ]) { await expectLater( endpoints.files.download(signedIn(person), id), throwsA(isA()), ); } }); test('keeps no file whose content has another hash', () async { expect( await add(alice, image, as: sha256.convert([7]).toString()), false, ); expect(await _stored(session), isEmpty); }); test('refuses what is no SHA-256, and sizes out of range', () async { for (final (hash, size) in [ ('not a hash', 1), (imageHash.toUpperCase(), 1), (imageHash, 0), (imageHash, 65), ]) { await expectLater( endpoints.files.startUpload(signedIn(alice), hash, size), throwsA(isA()), reason: '$hash $size', ); } // Sealed, it may be larger by what sealing adds. expect( await endpoints.files.startUpload( signedIn(alice), imageHash, 65, encrypted: true, ), isNotNull, ); }); test('keeps a file said to be sealed only when it starts as one; the ' "host's own rule decides the rest", () async { expect(await add(alice, image, encrypted: true), isFalse); final sealed = [...ascii.encode('HLKF'), 1, 9, 9]; expect(await add(alice, sealed, encrypted: true), isTrue); useBase( sessionBuilder, files: store(accepts: FileStore.acceptsSealedOnly), ); expect(await add(bob, image), isFalse); expect(await add(bob, sealed, encrypted: true), isTrue); }); group('room', () { test('a person keeps no more than their room; an allowance gives one ' 'person more', () async { fileLimits = const FileLimits(identifiedBytes: 10, instanceBytes: null); await expectLater( endpoints.files.startUpload(signedIn(alice), imageHash, 11), throwsA(isA()), ); await FileAllowance.db.insertRow( session, FileAllowance(authUserId: alice, maxBytes: 64), ); expect( await endpoints.files.startUpload(signedIn(alice), imageHash, 11), isNotNull, ); await expectLater( endpoints.files.startUpload(signedIn(bob), imageHash, 11), throwsA(isA()), ); }); test("counts the files the space's live rows name, and those claimed " 'lately', () async { await add(alice, image); row(alice, imageHash, {alice}); final usage = await endpoints.files.usage(signedIn(alice)); expect(usage.usedBytes, image.length); rows.clear(); await _claimed( session, alice, imageHash, DateTime.now().toUtc().subtract(const Duration(days: 8)), ); expect( (await endpoints.files.usage(signedIn(alice))).usedBytes, 0, reason: 'no live row names it, and it was claimed long ago', ); }); test("the operator's allowance goes with the person", () async { await FileAllowance.db.insertRow( session, FileAllowance(authUserId: alice, maxBytes: 64), ); await deletePerson(session, alice); expect( await FileAllowance.db.find( session, where: (t) => t.authUserId.equals(alice), ), isEmpty, ); }); }); group('pruning', () { final longAgo = DateTime.now().toUtc().subtract(const Duration(days: 8)); test('deletes files no live row names, claimed long ago; keeps the ' 'rest', () async { final other = utf8.encode('another'); final otherHash = sha256.convert(other).toString(); await add(alice, image); await add(alice, other); row(alice, imageHash, {alice}); await _claimed(session, alice, imageHash, longAgo); await _claimed(session, alice, otherHash, longAgo); expect(await pruneUnreferencedFiles(session, now: DateTime.now()), 1); expect(await _stored(session), {storedFilePath(alice, imageHash)}); }); test('keeps a file claimed lately, named or not', () async { await add(alice, image); expect(await pruneUnreferencedFiles(session, now: DateTime.now()), 0); }); }); }); } /// Uploads [bytes] as the upload [description] says, standing in for the /// app's HTTP upload, as in Halkyon Learn's tests. Future _upload( Session session, String description, List bytes, ) async { final url = Uri.parse( (jsonDecode(description) as Map)['url']! as String, ); final storage = Serverpod.instance.storage[url.queryParameters['storage']!]! as DatabaseCloudStorage; await storage.storeUnverifiedFile( session: session, path: url.queryParameters['path']!, byteData: ByteData.sublistView(Uint8List.fromList(bytes)), ); } /// The stored files' paths. Future> _stored(Session session) async => { for (final [path as String] in await session.db.unsafeQuery( 'SELECT "path" FROM "serverpod_cloud_storage" WHERE "path" LIKE ' "'spaces/%'", )) path, }; /// Makes the stored file with [hash] of [space] last claimed [at]. Future _claimed( Session session, UuidValue space, String hash, DateTime at, ) => CloudStorageEntry.db.updateWhere( session, columnValues: (t) => [t.addedTime(at)], where: (t) => t.storageId.equals(fileStorage) & t.path.equals(storedFilePath(space, hash)), );