getRoutes()->match(Request::create('/api/file/dav/status', 'GET')); $this->assertSame(FileDavController::class, $route->getActionName()); $this->assertSame('dav', $route->parameter('method')); $this->assertSame('status', $route->parameter('action')); $response = app('router')->dispatch(Request::create('/api/file/dav/missing', 'GET')); $this->assertStringContainsString('404 not found (dav/missing)', $response->getContent()); } public function test_protocol_route_accepts_webdav_methods_and_captures_path(): void { foreach (['OPTIONS', 'PROPFIND', 'PROPPATCH', 'HEAD', 'GET', 'PUT', 'MKCOL', 'COPY', 'MOVE', 'DELETE', 'LOCK', 'UNLOCK'] as $method) { $route = app('router')->getRoutes()->match(Request::create('/dav/files/folder/a.txt', $method)); $this->assertSame(WebDavProtocolController::class, $route->getActionName()); $this->assertSame('files/folder/a.txt', $route->parameter('path')); } } public function test_admin_configuration_is_bounded_and_consistent(): void { $setting = WebDavConfig::normalizeAdminInput([ 'webdav_enabled' => 'open', 'webdav_permission_type' => 'appoint', 'webdav_permission_userids' => ['2', 2, 0, -1], 'webdav_max_credentials' => 100, 'webdav_default_expire_days' => 90, 'webdav_max_expire_days' => 30, 'webdav_max_file_bytes' => PHP_INT_MAX, 'webdav_copy_max_nodes' => 0, 'webdav_audit_retention_days' => 1, ]); $this->assertSame('open', $setting['webdav_enabled']); $this->assertSame('appoint', $setting['webdav_permission_type']); $this->assertSame([2], $setting['webdav_permission_userids']); $this->assertSame(20, $setting['webdav_max_credentials']); $this->assertSame(30, $setting['webdav_default_expire_days']); $this->assertSame(30, $setting['webdav_max_expire_days']); $this->assertSame(config('dootask.webdav.max_file_bytes'), $setting['webdav_max_file_bytes']); $this->assertSame(1, $setting['webdav_copy_max_nodes']); $this->assertSame(7, $setting['webdav_audit_retention_days']); } public function test_path_conflicts_include_owner_path_and_file_ids(): void { $user = User::query()->firstOrFail(); $folder = File::createInstance([ 'pid' => 0, 'pids' => '', 'userid' => $user->userid, 'name' => 'WebDAV conflict folder', 'ext' => '', 'type' => 'folder', ]); $folder->save(); $files = collect([1, 2])->map(function () use ($folder, $user) { $file = File::createInstance([ 'pid' => $folder->id, 'pids' => ",{$folder->id},", 'userid' => $user->userid, 'name' => 'duplicate', 'ext' => 'txt', 'type' => 'file', ]); $file->save(); return $file; }); $result = WebDavConfig::pathConflicts(1, 100, $user); $conflict = collect($result['data'])->firstWhere('path', '/WebDAV conflict folder/duplicate.txt'); $this->assertNotNull($conflict); $this->assertSame(intval($user->userid), $conflict['owner']['userid']); $this->assertSame(intval($folder->id), $conflict['parent_id']); $this->assertSame($files->pluck('id')->map('intval')->all(), array_column($conflict['files'], 'id')); $this->assertSame('duplicate.txt', $conflict['files'][0]['full_name']); $this->assertTrue($conflict['files'][0]['can_open_location']); $this->assertSame('mine', $conflict['files'][0]['location_board']); $this->assertSame(intval($folder->id), $conflict['files'][0]['location_parent_id']); (new FileSystemService())->rename($user, $files->first(), 'renamed.txt'); $updated = WebDavConfig::pathConflicts(1, 100); $this->assertNull(collect($updated['data'])->firstWhere('path', '/WebDAV conflict folder/duplicate.txt')); } public function test_path_conflicts_only_expose_locations_allowed_by_existing_file_permissions(): void { $owner = User::query()->firstOrFail(); $admin = User::createInstance([ 'userid' => intval($owner->userid) + 1000001, 'identity' => ',admin,', ]); $admin->save(); $sharedFiles = collect([1, 2])->map(function () use ($owner, $admin) { $file = File::createInstance([ 'pid' => 0, 'pids' => '', 'userid' => $owner->userid, 'name' => 'shared-conflict', 'ext' => 'txt', 'type' => 'file', ]); $file->save(); $file->share = 1; $file->pshare = $file->id; $file->save(); FileUser::createInstance([ 'file_id' => $file->id, 'userid' => $admin->userid, 'permission' => 0, ])->save(); return $file; }); collect([1, 2])->each(function () use ($owner) { File::createInstance([ 'pid' => 0, 'pids' => '', 'userid' => $owner->userid, 'name' => 'private-conflict', 'ext' => 'txt', 'type' => 'file', ])->save(); }); $result = WebDavConfig::pathConflicts(1, 100, $admin); $shared = collect($result['data'])->firstWhere('path', '/shared-conflict.txt'); $private = collect($result['data'])->firstWhere('path', '/private-conflict.txt'); $this->assertNotNull($shared); $this->assertSame($sharedFiles->pluck('id')->map('intval')->all(), array_column($shared['files'], 'id')); $this->assertTrue($shared['files'][0]['can_open_location']); $this->assertSame('shared', $shared['files'][0]['location_board']); $this->assertSame(0, $shared['files'][0]['location_parent_id']); $this->assertNotNull($private); $this->assertFalse($private['files'][0]['can_open_location']); $this->assertNull($private['files'][0]['location_board']); $this->assertNull($private['files'][0]['location_parent_id']); } public function test_file_errors_map_to_webdav_status_categories(): void { $this->assertInstanceOf(NotFound::class, WebDavExceptionMapper::map(new \App\Exceptions\ApiException('文件不存在'))); $this->assertInstanceOf(Conflict::class, WebDavExceptionMapper::map(new \App\Exceptions\ApiException('文件已存在'))); $this->assertSame(413, WebDavExceptionMapper::map(new \App\Exceptions\ApiException('文件大小超过限制'))->getHTTPCode()); $this->assertInstanceOf(Forbidden::class, WebDavExceptionMapper::map(new \App\Exceptions\ApiException('没有修改写入权限'))); } public function test_admin_can_rename_another_users_conflicting_file_with_audit_log(): void { $owner = User::query()->firstOrFail(); $admin = User::createInstance([ 'userid' => intval($owner->userid) + 1000000, 'identity' => ',admin,', ]); $files = collect([1, 2])->map(function () use ($owner) { $file = File::createInstance([ 'pid' => 0, 'pids' => '', 'userid' => $owner->userid, 'name' => 'admin-conflict', 'ext' => 'txt', 'type' => 'file', ]); $file->save(); return $file; }); $renamed = (new WebDavConflictService())->renameAsAdmin( $admin, intval($files->first()->id), 'admin-renamed.txt', 'test-request', '127.0.0.1', 'phpunit' ); $this->assertSame('admin-renamed.txt', $renamed->getNameAndExt()); $this->assertSame(intval($owner->userid), intval($renamed->userid)); $this->assertSame(1, File::whereName('admin-conflict')->whereExt('txt')->count()); $this->assertDatabaseHas('webdav_operation_logs', [ 'userid' => $admin->userid, 'method' => 'ADMIN_RENAME', 'file_id' => $renamed->id, 'status' => 200, ]); $log = WebDavOperationLog::whereFileId($renamed->id)->whereMethod('ADMIN_RENAME')->latest('id')->firstOrFail(); $this->assertStringContainsString('admin-conflict.txt -> admin-renamed.txt', (string) $log->result); } public function test_only_inactive_credentials_can_be_permanently_deleted_with_audit_retained(): void { $user = User::query()->firstOrFail(); [$credential] = WebDavCredential::issue($user, 'delete-test', 30); $service = new WebDavCredentialService(); try { $service->deleteInactive($user, intval($credential->id), 'delete-active', '127.0.0.1', 'phpunit'); $this->fail('Active credential deletion should be rejected.'); } catch (\App\Exceptions\ApiException $exception) { $this->assertSame('有效的应用密码请先撤销', $exception->getMessage()); } $credential->revoke(); WebDavLock::createInstance([ 'token' => 'delete-test-lock', 'userid' => $user->userid, 'credential_id' => $credential->id, 'uri' => 'files/delete-test', 'uri_hash' => hash('sha256', 'files/delete-test'), 'scope' => 'exclusive', 'depth' => 'infinity', 'timeout_at' => now()->addHour(), ])->save(); $service->deleteInactive($user, intval($credential->id), 'delete-inactive', '127.0.0.1', 'phpunit'); $this->assertNull(WebDavCredential::find($credential->id)); $this->assertFalse(WebDavLock::whereCredentialId($credential->id)->exists()); $this->assertDatabaseHas('webdav_operation_logs', [ 'request_id' => 'delete-inactive', 'userid' => $user->userid, 'credential_id' => $credential->id, 'method' => 'CREDENTIAL_DELETE', 'status' => 200, ]); } public function test_server_factory_registers_required_plugins(): void { $user = User::createInstance(['userid' => 123]); $credential = WebDavCredential::createInstance(['id' => 456]); $server = (new WebDavServerFactory())->make($user, $credential); $this->assertNotNull($server->getPlugin('locks')); $this->assertNotNull($server->getPlugin('property-storage')); $this->assertNotNull($server->getPlugin('dootask-move')); $this->assertNotNull($server->getPlugin('dootask-copy-guard')); $this->assertFalse(\Sabre\DAV\Server::$exposeVersion); } public function test_generated_api_map_contains_webdav_management_routes(): void { $map = file_get_contents(base_path('routes/api-map.md')); $this->assertStringContainsString('api/file/dav/status', $map); $this->assertStringContainsString('dav__status()', $map); $this->assertStringContainsString('api/file/dav/adminsetting', $map); $this->assertStringContainsString('api/file/dav/delete', $map); } public function test_protocol_bridge_handles_sabre_null_body_as_empty_response(): void { $controller = new WebDavProtocolController(); $method = new \ReflectionMethod($controller, 'toSymfonyResponse'); $response = $method->invoke($controller, new SabreResponse(201)); $this->assertSame(201, $response->getStatusCode()); $this->assertSame('', $response->getContent()); } }