164 lines
5.9 KiB
Dart
164 lines
5.9 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:immich_mobile/data/server/file_vault_api.dart';
|
|
import 'package:immich_mobile/domain/models/file_vault.model.dart';
|
|
import 'package:immich_mobile/platform/file_vault_api.g.dart';
|
|
import 'package:immich_mobile/services/api.service.dart';
|
|
import 'package:openapi/api.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
class TestVaultApiService extends Fake implements ApiService {
|
|
TestVaultApiService(this.client);
|
|
final ApiClient client;
|
|
@override
|
|
ApiClient get apiClient => client;
|
|
}
|
|
|
|
void main() {
|
|
late Directory temporary;
|
|
setUp(() async {
|
|
temporary = await Directory.systemTemp.createTemp('immich-file-vault-test-');
|
|
});
|
|
tearDown(() async {
|
|
await temporary.delete(recursive: true);
|
|
});
|
|
|
|
FileVaultApiData repository(Future<http.Response> Function(http.Request) handler) {
|
|
final client = ApiClient(basePath: 'https://immich.example/api')..client = MockClient(handler);
|
|
client.addDefaultHeader('Authorization', 'Bearer test-session');
|
|
return FileVaultApiData(
|
|
TestVaultApiService(client),
|
|
FileVaultPlatform(),
|
|
temporaryDirectory: () async => temporary,
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> entry({List<int> bytes = const [], String name = 'Работа.dwg'}) => {
|
|
'id': '00000000-0000-4000-8000-000000000001',
|
|
'parentId': null,
|
|
'name': name,
|
|
'isFolder': false,
|
|
'size': bytes.length,
|
|
'checksum': sha256.convert(bytes).toString(),
|
|
'createdAt': '2026-10-31T00:00:00.000Z',
|
|
'updatedAt': '2026-10-31T00:00:00.000Z',
|
|
'deletedAt': null,
|
|
};
|
|
|
|
test('renaming preserves the current folder and moving to root sends explicit null', () async {
|
|
final requests = <Map<String, dynamic>>[];
|
|
final data = repository((request) async {
|
|
requests.add(jsonDecode(request.body) as Map<String, dynamic>);
|
|
return http.Response(jsonEncode(entry()), 200, headers: {'content-type': 'application/json; charset=utf-8'});
|
|
});
|
|
await data.rename('00000000-0000-4000-8000-000000000001', 'Renamed.pdf');
|
|
await data.move('00000000-0000-4000-8000-000000000001', null);
|
|
expect(requests, [
|
|
{'name': 'Renamed.pdf'},
|
|
{'parentId': null},
|
|
]);
|
|
});
|
|
|
|
test('uploads original binary bytes with the private session and verifies the returned hash', () async {
|
|
final bytes = [0, 1, 255, 128, 13, 10, 0];
|
|
final file = File(p.join(temporary.path, 'Работа.dwg'));
|
|
await file.writeAsBytes(bytes);
|
|
final data = repository((request) async {
|
|
expect(request.url.queryParameters['name'], 'Работа.dwg');
|
|
expect(request.headers['Authorization'], 'Bearer test-session');
|
|
expect(request.bodyBytes, bytes);
|
|
return http.Response(
|
|
jsonEncode(entry(bytes: bytes)),
|
|
201,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
);
|
|
});
|
|
var progress = 0;
|
|
await data.upload(file.path, 'Работа.dwg', null, (sent, total) {
|
|
progress = sent;
|
|
expect(total, bytes.length);
|
|
});
|
|
expect(progress, bytes.length);
|
|
});
|
|
|
|
test('reports rejected uploads rather than treating them as successful', () async {
|
|
final file = File(p.join(temporary.path, 'private.pdf'));
|
|
await file.writeAsString('private');
|
|
final data = repository((_) async => http.Response('{"message":"Name exists"}', 409));
|
|
await expectLater(
|
|
data.upload(file.path, 'private.pdf', null, (_, _) {}),
|
|
throwsA(isA<ApiException>().having((error) => error.code, 'code', 409)),
|
|
);
|
|
});
|
|
|
|
test('does not confirm an upload when the returned original checksum differs', () async {
|
|
final file = File(p.join(temporary.path, 'private.pdf'));
|
|
await file.writeAsString('private');
|
|
final data = repository(
|
|
(_) async => http.Response(
|
|
jsonEncode(entry(bytes: [1, 2, 3])),
|
|
201,
|
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
|
),
|
|
);
|
|
await expectLater(data.upload(file.path, 'private.pdf', null, (_, _) {}), throwsFormatException);
|
|
});
|
|
|
|
test('downloads into private temporary storage and validates original bytes', () async {
|
|
final bytes = [255, 0, 1, 13, 10, 128];
|
|
final data = repository((request) async {
|
|
expect(request.headers['Authorization'], 'Bearer test-session');
|
|
return http.Response.bytes(bytes, 200);
|
|
});
|
|
final file = VaultEntry(
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: 'Работа.zip',
|
|
parentId: null,
|
|
isFolder: false,
|
|
size: bytes.length,
|
|
checksum: sha256.convert(bytes).toString(),
|
|
);
|
|
final result = await data.download(file, (_, _) {});
|
|
expect(p.isWithin(temporary.path, result.path), isTrue);
|
|
expect(await result.readAsBytes(), bytes);
|
|
});
|
|
|
|
test('removes partial downloads when size or checksum verification fails', () async {
|
|
final data = repository((_) async => http.Response.bytes([1, 2], 200));
|
|
const file = VaultEntry(
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: 'broken.pdf',
|
|
parentId: null,
|
|
isFolder: false,
|
|
size: 3,
|
|
checksum: 'invalid',
|
|
);
|
|
await expectLater(data.download(file, (_, _) {}), throwsFormatException);
|
|
expect(await temporary.list(recursive: true).where((entry) => entry is File).toList(), isEmpty);
|
|
});
|
|
|
|
test('rejects a server-supplied path traversal identifier before any filesystem or network access', () async {
|
|
var requested = false;
|
|
final data = repository((_) async {
|
|
requested = true;
|
|
return http.Response('private', 200);
|
|
});
|
|
const file = VaultEntry(
|
|
id: '../../outside',
|
|
name: 'database',
|
|
parentId: null,
|
|
isFolder: false,
|
|
size: 7,
|
|
checksum: null,
|
|
);
|
|
await expectLater(data.download(file, (_, _) {}), throwsFormatException);
|
|
expect(requested, isFalse);
|
|
expect(await temporary.list().toList(), isEmpty);
|
|
});
|
|
}
|