import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:process_run/cmd_run.dart'; import 'package:test/test.dart'; const String nextcloudVersion = '24.0.3'; const String defaultUsername = 'test'; const String defaultPassword = 'supersafepasswordtocircumventpasswordpolicies'; class TestNextcloudClient extends NextcloudClient { TestNextcloudClient( super.baseURL, this.containerID, { super.username, super.password, super.language, super.appType, super.userAgentOverride, }); final String containerID; Future runOccCommand(final List args) async { final result = await runExecutableArguments( 'docker', [ 'exec', containerID, 'php', '-f', 'occ', ...args, ], stdout: stdout, stderr: stderr, ); if (result.exitCode != 0) { throw Exception('Failed to run occ command'); } } Future destroy() => runExecutableArguments( 'docker', [ 'kill', containerID, ], ); Future collectLogs() async { final apacheLogs = (await runExecutableArguments( 'docker', [ 'logs', containerID, ], stdoutEncoding: utf8, )) .stdout as String; final nextcloudLogs = (await runExecutableArguments( 'docker', [ 'exec', containerID, 'cat', 'data/nextcloud.log', ], stdoutEncoding: utf8, )) .stdout as String; return '$apacheLogs\n\n$nextcloudLogs'; } } class TestHelper { static Future prepareDockerImage({ final List? users, final List? apps, }) async { final hash = sha1 .convert( utf8.encode( [ if (users != null) for (final user in users) user.toString(), if (apps != null) ...apps, ].join(), ), ) .toString(); final dockerImageName = 'nextcloud-neon-$hash'; final inputStream = StreamController>(); final process = runExecutableArguments( 'docker', [ 'build', '-t', dockerImageName, '-f', '-', './test', ], stdout: stdout, stderr: stderr, stdin: inputStream.stream, ); inputStream.add( utf8.encode( TestDockerHelper.generateInstructions( nextcloudVersion, users: users, apps: apps, ), ), ); await inputStream.close(); final result = await process; if (result.exitCode != 0) { throw Exception('Failed to build docker image'); } return dockerImageName; } static Future getPreparedClient( final String dockerImageName, { final String? username = defaultUsername, final String? password = defaultPassword, final bool useAppPassword = false, final AppType appType = AppType.unknown, final String? userAgentOverride, }) async { // ignore: prefer_asserts_with_message assert(!useAppPassword || (username != null && password != null)); final port = randomPort(); final result = await runExecutableArguments( 'docker', [ 'run', '--rm', '-d', '-p', '$port:80', '--add-host', 'host.docker.internal:host-gateway', dockerImageName, ], ); if (result.exitCode != 0) { throw Exception('Failed to run docker container: ${result.stderr}'); } final containerID = result.stdout.toString().replaceAll('\n', ''); var clientPassword = password; if (useAppPassword) { final inputStream = StreamController>(); final process = runExecutableArguments( 'docker', [ 'exec', '-i', containerID, 'php', '-f', 'occ', 'user:add-app-password', username!, ], stdin: inputStream.stream, ); inputStream.add(utf8.encode(password!)); await inputStream.close(); final result = await process; if (result.exitCode != 0) { throw Exception('Failed to run generate app password command\n${result.stderr}\n${result.stdout}'); } clientPassword = (result.stdout as String).split('\n')[1]; } final client = TestNextcloudClient( 'http://localhost:$port', containerID, username: username, password: clientPassword, appType: appType, userAgentOverride: userAgentOverride, ); var iteration = 0; while (true) { try { await client.core.getStatus(); break; } catch (_) { iteration++; await Future.delayed(const Duration(milliseconds: 100)); if (iteration > 50) { throw Exception('Failed to wait for Nextcloud instance'); } } } return client; } } class TestNextcloudUser { TestNextcloudUser( this.username, { this.displayName, }); final String username; final String? displayName; } class TestDockerHelper { static String generateInstructions( final String nextcloudVersion, { final List? users, final List? apps, }) { users?.sort((final a, final b) => a.username.compareTo(b.username)); apps?.sort(); final instructions = [ generateFromNextcloudImageInstruction(nextcloudVersion), 'WORKDIR /usr/src/nextcloud', 'RUN chown -R www-data:www-data .', 'USER www-data', 'RUN ./occ maintenance:install --admin-user admin --admin-pass $defaultPassword --admin-email admin@example.com', generateCreateTestUserInstruction(), if (users != null) ...[ for (final user in users) ...[ generateCreateUserInstruction(user), ], ], if (apps != null) ...[ for (final app in apps) ...[ generateInstallAppInstruction(app), ], ], // Required to workaround restrictions for localhost and http only push proxies 'RUN ./occ config:system:set allow_local_remote_servers --value=true', 'RUN sed -i "s/localhost/host.docker.internal/" /usr/src/nextcloud/apps/notifications/lib/Controller/PushController.php', 'ADD overlay /usr/src/nextcloud/', '', ]; return instructions.join('\n'); } static String generateFromNextcloudImageInstruction( final String nextcloudVersion, ) => 'FROM nextcloud:$nextcloudVersion'; static String generateCreateTestUserInstruction() => generateCreateUserInstruction( TestNextcloudUser( defaultUsername, displayName: 'Test', ), ); static String generateCreateUserInstruction(final TestNextcloudUser user) => 'RUN OC_PASS="$defaultPassword" ./occ user:add --password-from-env ${user.displayName != null ? '--display-name="${user.displayName}"' : ''} ${user.username}'; static String generateInstallAppInstruction( final String appName, ) => 'RUN ./occ app:install $appName'; } /// Validates that the response matches the schema /// /// [cleanResponse] can be used for compatibility reasons, because some APIs are very inconsistent in their responses and don't populate all fields Future validateResponse( final ApiInstance api, final Future input, { final bool cleanResponse = false, }) async { final response = await input; if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await decodeBodyBytes(response)); } if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { var body = json.decode(await decodeBodyBytes(response)); if (cleanResponse) { body = removeNulls(body); } var output = await api.apiClient.deserializeAsync( json.encode(body), T.toString(), ); if (output is List) { output = output.map((final b) => b as U).toList(); } output = output as T; var parsedBody = json.decode(json.encode(output)); if (cleanResponse) { parsedBody = removeNulls(parsedBody); } expect(parsedBody, body); return output; } return null; } Map removeNullsFromMap(final Map json) => json ..removeWhere((final key, final value) => value == null) ..map((final key, final value) => MapEntry(key, removeNulls(value))); List removeNullsFromList(final List list) => list ..removeWhere((final value) => value == null) ..map(removeNulls).toList(); T removeNulls(final T e) => ((e is List) ? removeNullsFromList(e) : (e is Map ? removeNullsFromMap(e) : e)) as T; extension ListExtension on List { List removeNulls() => removeNullsFromList(this); } extension MapExtension on Map { Map removeNulls() => removeNullsFromMap(this); } int randomPort() => 1024 + Random().nextInt(65535 - 1024); void expectDateInReasonableTimeRange(final DateTime actual, final DateTime expected) { const duration = Duration(seconds: 5); expect(actual.isAfter(expected.subtract(duration)), isTrue); expect(actual.isBefore(expected.add(duration)), isTrue); }