Files
Immich/mobile/test/unit/file_vault_api_test.dart
T

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);
});
}