Merge pull request #15407 from opf/implementation/54355-use-authentication-in-folderfilesfileidsdeepquery

[#54355] Use authentication in FolderFilesFileIdsDeepQuery
This commit is contained in:
Eric Schubert
2024-04-30 15:16:48 +02:00
committed by GitHub
23 changed files with 1027 additions and 431 deletions
-2
View File
@@ -1,5 +1,3 @@
version: "3.9"
services:
traefik:
image: traefik:latest
@@ -38,7 +38,7 @@ module Storages
register(:file_info, StorageInteraction::Nextcloud::FileInfoQuery)
register(:files_info, StorageInteraction::Nextcloud::FilesInfoQuery)
register(:files, StorageInteraction::Nextcloud::FilesQuery)
register(:folder_files_file_ids_deep_query, StorageInteraction::Nextcloud::FolderFilesFileIdsDeepQuery)
register(:folder_files_file_ids_deep, StorageInteraction::Nextcloud::FolderFilesFileIdsDeepQuery)
register(:propfind, StorageInteraction::Nextcloud::Internal::PropfindQuery)
register(:group_users, StorageInteraction::Nextcloud::GroupUsersQuery)
register(:upload_link, StorageInteraction::Nextcloud::UploadLinkQuery)
@@ -38,7 +38,7 @@ module Storages
register(:file_info, StorageInteraction::OneDrive::FileInfoQuery)
register(:files_info, StorageInteraction::OneDrive::FilesInfoQuery)
register(:open_file_link, StorageInteraction::OneDrive::OpenFileLinkQuery)
register(:folder_files_file_ids_deep_query, StorageInteraction::OneDrive::FolderFilesFileIdsDeepQuery)
register(:folder_files_file_ids_deep, StorageInteraction::OneDrive::FolderFilesFileIdsDeepQuery)
register(:open_storage, StorageInteraction::OneDrive::OpenStorageQuery)
register(:upload_link, StorageInteraction::OneDrive::UploadLinkQuery)
end
@@ -29,7 +29,7 @@
module Storages::Peripherals::StorageInteraction::Nextcloud
class FileIdsQuery
def initialize(storage)
@query = ::Storages::Peripherals::StorageInteraction::Nextcloud::Internal::PropfindQuery.new(storage)
@query = ::Storages::Peripherals::StorageInteraction::Nextcloud::Internal::PropfindQueryLegacy.new(storage)
end
def self.call(storage:, path:)
@@ -28,19 +28,44 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages::Peripherals::StorageInteraction::Nextcloud
class FolderFilesFileIdsDeepQuery
def self.call(storage:, folder:)
::Storages::Peripherals::Registry
.resolve("nextcloud.queries.propfind")
.call(
storage:,
depth: "infinity",
path: folder.path,
# nc:acl-list is only required to avoid https://community.openproject.org/wp/49628. See comment #4.
props: %w[oc:fileid nc:acl-list]
).map do |obj|
obj.transform_values { |value| Storages::StorageFileInfo.from_id(value["fileid"]) }
module Storages
module Peripherals
module StorageInteraction
module Nextcloud
class FolderFilesFileIdsDeepQuery
def self.call(storage:, auth_strategy:, folder:)
new(storage).call(auth_strategy:, folder:)
end
def initialize(storage)
@storage = storage
@propfind_query = Internal::PropfindQuery.new(storage)
end
def call(auth_strategy:, folder:)
origin_user_id = Util.origin_user_id(caller: self.class, storage: @storage, auth_strategy:)
.on_failure do |result|
return result
end
Authentication[auth_strategy].call(storage: @storage, http_options:) do |http|
# nc:acl-list is only required to avoid https://community.openproject.org/wp/49628. See comment #4.
@propfind_query.call(http:,
username: origin_user_id.result,
path: folder.path,
props: %w[oc:fileid nc:acl-list])
.map do |obj|
obj.transform_values { |value| StorageFileId.new(id: value["fileid"]) }
end
end
end
private
def http_options
Util.webdav_request_with_depth("infinity")
end
end
end
end
end
@@ -28,114 +28,131 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages::Peripherals::StorageInteraction::Nextcloud::Internal
class PropfindQuery
UTIL = ::Storages::Peripherals::StorageInteraction::Nextcloud::Util
module Storages
module Peripherals
module StorageInteraction
module Nextcloud
module Internal
class PropfindQuery
# Only for information purposes currently.
# Probably a bit later we could validate `#call` parameters.
#
# DEPTH = %w[0 1 infinity].freeze
# POSSIBLE_PROPS = %w[
# d:getlastmodified
# d:getetag
# d:getcontenttype
# d:resourcetype
# d:getcontentlength
# d:permissions
# d:size
# oc:id
# oc:fileid
# oc:favorite
# oc:comments-href
# oc:comments-count
# oc:comments-unread
# oc:owner-id
# oc:owner-display-name
# oc:share-types
# oc:checksums
# oc:size
# nc:has-preview
# nc:rich-workspace
# nc:contained-folder-count
# nc:contained-file-count
# nc:acl-list
# ].freeze
# Only for information purposes currently.
# Probably a bit later we could validate `#call` parameters.
#
# DEPTH = %w[0 1 infinity].freeze
# POSSIBLE_PROPS = %w[
# d:getlastmodified
# d:getetag
# d:getcontenttype
# d:resourcetype
# d:getcontentlength
# d:permissions
# d:size
# oc:id
# oc:fileid
# oc:favorite
# oc:comments-href
# oc:comments-count
# oc:comments-unread
# oc:owner-id
# oc:owner-display-name
# oc:share-types
# oc:checksums
# oc:size
# nc:has-preview
# nc:rich-workspace
# nc:contained-folder-count
# nc:contained-file-count
# nc:acl-list
# ].freeze
def self.call(storage:, http:, username:, path:, props:)
new(storage).call(http:, username:, path:, props:)
end
def initialize(storage)
@uri = storage.uri
@username = storage.username
@password = storage.password
@group = storage.group
end
def initialize(storage)
@storage = storage
end
def self.call(storage:, depth:, path:, props:)
new(storage).call(depth:, path:, props:)
end
def call(http:, username:, path:, props:)
request_uri = Util.join_uri_path(base_uri, CGI.escapeURIComponent(username), Util.escape_path(path))
response = http.request(:propfind, request_uri, xml: request_body(props))
# rubocop:disable Metrics/AbcSize
def call(depth:, path:, props:)
body = Nokogiri::XML::Builder.new do |xml|
xml["d"].propfind(
"xmlns:d" => "DAV:",
"xmlns:oc" => "http://owncloud.org/ns",
"xmlns:nc" => "http://nextcloud.org/ns"
) do
xml["d"].prop do
props.each do |prop|
namespace, property = prop.split(":")
xml[namespace].send(property)
handle_response(response, username)
end
private
def handle_response(response, username)
case response
in { status: 200..299 }
success_result(response, username)
in { status: 401 }
Util.failure(code: :unauthorized,
data: Util.error_data_from_response(caller: self.class, response:),
log_message: "Outbound request not authorized!")
in { status: 404 }
Util.failure(code: :not_found,
data: Util.error_data_from_response(caller: self.class, response:),
log_message: "Outbound request destination not found!")
in { status: 405 }
Util.failure(code: :not_allowed,
data: Util.error_data_from_response(caller: self.class, response:),
log_message: "Outbound request method not allowed!")
else
Util.failure(code: :error,
data: Util.error_data_from_response(caller: self.class, response:),
log_message: "Outbound request failed with unknown error!")
end
end
# rubocop:disable Metrics/AbcSize
def success_result(response, username)
doc = Nokogiri::XML(response.body.to_s)
result = {}
doc.xpath("/d:multistatus/d:response").each do |resource_section|
resource = resource_path(resource_section, username)
result[resource] = {}
# In future it could be useful to respond not only with found, but not found props as well
# resource_section.xpath("d:propstat[d:status[text() = 'HTTP/1.1 404 Not Found']]/d:prop/*")
resource_section.xpath("d:propstat[d:status[text() = 'HTTP/1.1 200 OK']]/d:prop/*").each do |node|
result[resource][node.name.to_s] = node.text.strip
end
end
ServiceResult.success(result:)
end
# rubocop:enable Metrics/AbcSize
def base_uri = "#{@storage.uri}remote.php/dav/files"
def resource_path(section, username)
path = CGI.unescape(section.xpath("d:href").text.strip)
.gsub!(Util.join_uri_path(URI(base_uri).path, username), "")
path.end_with?("/") && path.length > 1 ? path.chop : path
end
def request_body(props)
Nokogiri::XML::Builder.new do |xml|
xml["d"].propfind(
"xmlns:d" => "DAV:",
"xmlns:oc" => "http://owncloud.org/ns",
"xmlns:nc" => "http://nextcloud.org/ns"
) do
xml["d"].prop do
props.each do |prop|
namespace, property = prop.split(":")
xml[namespace].send(property)
end
end
end
end.to_xml
end
end
end
end.to_xml
response = OpenProject
.httpx
.basic_auth(@username, @password)
.with(headers: { "Depth" => depth })
.request(
"PROPFIND",
UTIL.join_uri_path(
@uri,
"remote.php/dav/files",
CGI.escapeURIComponent(@username),
UTIL.escape_path(path)
),
xml: body
)
error_data = Storages::StorageErrorData.new(source: self.class, payload: response)
case response
in { status: 200..299 }
doc = Nokogiri::XML(response.body.to_s)
result = {}
doc.xpath("/d:multistatus/d:response").each do |resource_section|
resource = CGI.unescape(resource_section.xpath("d:href").text.strip)
.gsub!(UTIL.join_uri_path(@uri.path, "/remote.php/dav/files/#{@username}/"), "")
result[resource] = {}
# In future it could be useful to respond not only with found, but not found props as well
# resource_section.xpath("d:propstat[d:status[text() = 'HTTP/1.1 404 Not Found']]/d:prop/*")
resource_section.xpath("d:propstat[d:status[text() = 'HTTP/1.1 200 OK']]/d:prop/*").each do |node|
result[resource][node.name.to_s] = node.text.strip
end
end
ServiceResult.success(result:)
in { status: 405 }
UTIL.error(:not_allowed, "Outbound request method not allowed", error_data)
in { status: 401 }
UTIL.error(:unauthorized, "Outbound request not authorized", error_data)
in { status: 404 }
UTIL.error(:not_found, "Outbound request destination not found", error_data)
else
UTIL.error(:error, "Outbound request failed", error_data)
end
end
# rubocop:enable Metrics/AbcSize
end
end
@@ -0,0 +1,141 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages::Peripherals::StorageInteraction::Nextcloud::Internal
class PropfindQueryLegacy
UTIL = ::Storages::Peripherals::StorageInteraction::Nextcloud::Util
# Only for information purposes currently.
# Probably a bit later we could validate `#call` parameters.
#
# DEPTH = %w[0 1 infinity].freeze
# POSSIBLE_PROPS = %w[
# d:getlastmodified
# d:getetag
# d:getcontenttype
# d:resourcetype
# d:getcontentlength
# d:permissions
# d:size
# oc:id
# oc:fileid
# oc:favorite
# oc:comments-href
# oc:comments-count
# oc:comments-unread
# oc:owner-id
# oc:owner-display-name
# oc:share-types
# oc:checksums
# oc:size
# nc:has-preview
# nc:rich-workspace
# nc:contained-folder-count
# nc:contained-file-count
# nc:acl-list
# ].freeze
def initialize(storage)
@uri = storage.uri
@username = storage.username
@password = storage.password
@group = storage.group
end
def self.call(storage:, depth:, path:, props:)
new(storage).call(depth:, path:, props:)
end
# rubocop:disable Metrics/AbcSize
def call(depth:, path:, props:)
body = Nokogiri::XML::Builder.new do |xml|
xml["d"].propfind(
"xmlns:d" => "DAV:",
"xmlns:oc" => "http://owncloud.org/ns",
"xmlns:nc" => "http://nextcloud.org/ns"
) do
xml["d"].prop do
props.each do |prop|
namespace, property = prop.split(":")
xml[namespace].send(property)
end
end
end
end.to_xml
response = OpenProject
.httpx
.basic_auth(@username, @password)
.with(headers: { "Depth" => depth })
.request(
"PROPFIND",
UTIL.join_uri_path(
@uri,
"remote.php/dav/files",
CGI.escapeURIComponent(@username),
UTIL.escape_path(path)
),
xml: body
)
error_data = Storages::StorageErrorData.new(source: self.class, payload: response)
case response
in { status: 200..299 }
doc = Nokogiri::XML(response.body.to_s)
result = {}
doc.xpath("/d:multistatus/d:response").each do |resource_section|
resource = CGI.unescape(resource_section.xpath("d:href").text.strip)
.gsub!(UTIL.join_uri_path(@uri.path, "/remote.php/dav/files/#{@username}/"), "")
result[resource] = {}
# In future it could be useful to respond not only with found, but not found props as well
# resource_section.xpath("d:propstat[d:status[text() = 'HTTP/1.1 404 Not Found']]/d:prop/*")
resource_section.xpath("d:propstat[d:status[text() = 'HTTP/1.1 200 OK']]/d:prop/*").each do |node|
result[resource][node.name.to_s] = node.text.strip
end
end
ServiceResult.success(result:)
in { status: 405 }
UTIL.error(:not_allowed, "Outbound request method not allowed", error_data)
in { status: 401 }
UTIL.error(:unauthorized, "Outbound request not authorized", error_data)
in { status: 404 }
UTIL.error(:not_found, "Outbound request destination not found", error_data)
else
UTIL.error(:error, "Outbound request failed", error_data)
end
end
# rubocop:enable Metrics/AbcSize
end
end
@@ -28,109 +28,89 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages::Peripherals::StorageInteraction::OneDrive
class FolderFilesFileIdsDeepQuery
FIELDS = %w[id name file folder parentReference].freeze
module Storages
module Peripherals
module StorageInteraction
module OneDrive
class FolderFilesFileIdsDeepQuery
CHILDREN_FIELDS = %w[id name file folder parentReference].freeze
FOLDER_FIELDS = %w[id name parentReference].freeze
def self.call(storage:, folder:)
new(storage).call(folder:)
end
def self.call(storage:, auth_strategy:, folder:)
new(storage).call(auth_strategy:, folder:)
end
def initialize(storage)
@storage = storage
@delegate = Internal::ChildrenQuery.new(storage)
end
def initialize(storage)
@storage = storage
@children_query = Internal::ChildrenQuery.new(storage)
@drive_item_query = Internal::DriveItemQuery.new(storage)
end
def call(folder:)
Util.using_admin_token(@storage) do |http|
fetch_result = fetch_folder(http, folder)
return fetch_result if fetch_result.failure?
def call(auth_strategy:, folder:)
Authentication[auth_strategy].call(storage: @storage) do |http|
fetch_result = fetch_folder(http, folder)
return fetch_result if fetch_result.failure?
file_ids_dictionary = fetch_result.result
queue = [folder]
file_ids_dictionary = fetch_result.result
queue = [folder]
while queue.any?
dir = queue.shift
while queue.any?
dir = queue.shift
visit = visit(http, dir)
return visit if visit.failure?
visit = visit(http, dir)
return visit if visit.failure?
entry, to_queue = visit.result.values_at(:entry, :to_queue)
file_ids_dictionary = file_ids_dictionary.merge(entry)
queue.concat(to_queue)
entry, to_queue = visit.result.values_at(:entry, :to_queue)
file_ids_dictionary = file_ids_dictionary.merge(entry)
queue.concat(to_queue)
end
ServiceResult.success(result: file_ids_dictionary)
end
end
private
def visit(http, folder)
call = @children_query.call(http:, folder:, fields: CHILDREN_FIELDS)
return call if call.failure?
entry = {}
to_queue = []
call.result[:value].each do |json|
new_entry, folder = parse_drive_item_info(json).values_at(:entry, :folder)
entry = entry.merge(new_entry)
if folder.present?
to_queue.append(folder)
end
end
ServiceResult.success(result: { entry:, to_queue: })
end
def parse_drive_item_info(json)
drive_item_id = json[:id]
location = Util.extract_location(json[:parentReference], json[:name])
entry = { location => StorageFileId.new(id: drive_item_id) }
folder = json[:folder].present? ? ParentFolder.new(drive_item_id) : nil
{ entry:, folder: }
end
def fetch_folder(http, folder)
result = @drive_item_query.call(http:, drive_item_id: folder.path, fields: FOLDER_FIELDS)
result.map do |json|
if folder.root?
{ "/" => StorageFileId.new(id: json[:id]) }
else
parse_drive_item_info(json)[:entry]
end
end
end
end
ServiceResult.success(result: file_ids_dictionary)
end
end
private
def visit(http, folder)
call = @delegate.call(http:, folder:, fields: FIELDS)
return call if call.failure?
entry = {}
to_queue = []
call.result[:value].each do |json|
new_entry, folder = parse_drive_item_info(json).values_at(:entry, :folder)
entry = entry.merge(new_entry)
if folder.present?
to_queue.append(folder)
end
end
ServiceResult.success(result: { entry:, to_queue: })
end
def parse_drive_item_info(json)
drive_item_id = json[:id]
location = Util.extract_location(json[:parentReference], json[:name])
entry = { location => Storages::StorageFileInfo.from_id(drive_item_id) }
folder = json[:folder].present? ? Storages::Peripherals::ParentFolder.new(drive_item_id) : nil
{ entry:, folder: }
end
# TODO: REMOVE WITH #51713, as this should be replaced by internal drive item query
# with harmonized interface for authentication
def fetch_folder(http, folder)
uri_path = if folder.root?
"/v1.0/drives/#{@storage.drive_id}/root"
else
"/v1.0/drives/#{@storage.drive_id}/items/#{folder}"
end
response = http.get(Util.join_uri_path(@storage.uri, "#{uri_path}?$select=id,name,parentReference"))
handle_responses(response).map do |json|
if folder.root?
{ "/" => Storages::StorageFileInfo.from_id(json[:id]) }
else
parse_drive_item_info(json)[:entry]
end
end
end
def handle_responses(response)
case response
in { status: 200..299 }
ServiceResult.success(result: response.json(symbolize_keys: true))
in { status: 404 }
ServiceResult.failure(result: :not_found,
errors: Util.storage_error(response:, code: :not_found, source: self))
in { status: 403 }
ServiceResult.failure(result: :forbidden,
errors: Util.storage_error(response:, code: :forbidden, source: self))
in { status: 401 }
ServiceResult.failure(result: :unauthorized,
errors: Util.storage_error(response:, code: :unauthorized, source: self))
else
data = ::Storages::StorageErrorData.new(source: self.class, payload: response)
ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:))
end
end
end
@@ -34,8 +34,6 @@ module Storages
module OneDrive
module Internal
class DriveItemQuery
Util = ::Storages::Peripherals::StorageInteraction::OneDrive::Util
def initialize(storage)
@storage = storage
end
@@ -53,11 +51,10 @@ module Storages
private
def make_file_request(drive_item_id, http, select_url_query)
response = http.get(Util.join_uri_path(@storage.uri, uri_path_for(drive_item_id) + select_url_query))
handle_responses(response)
handle_response http.get("#{@storage.uri}#{uri_path_for(drive_item_id)}#{select_url_query}")
end
def handle_responses(response)
def handle_response(response)
case response
in { status: 200..299 }
ServiceResult.success(result: response.json(symbolize_keys: true))
@@ -71,16 +68,16 @@ module Storages
ServiceResult.failure(result: :unauthorized,
errors: Util.storage_error(response:, code: :unauthorized, source: self.class))
else
data = ::Storages::StorageErrorData.new(source: self.class, payload: response)
ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:))
data = StorageErrorData.new(source: self.class, payload: response)
ServiceResult.failure(result: :error, errors: StorageError.new(code: :error, data:))
end
end
def uri_path_for(file_id)
if file_id == "/"
"/v1.0/drives/#{@storage.drive_id}/root"
"v1.0/drives/#{@storage.drive_id}/root"
else
"/v1.0/drives/#{@storage.drive_id}/items/#{file_id}"
"v1.0/drives/#{@storage.drive_id}/items/#{file_id}"
end
end
end
@@ -28,86 +28,98 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages::Peripherals::StorageInteraction::OneDrive::Util
using Storages::Peripherals::ServiceResultRefinements
module Storages
module Peripherals
module StorageInteraction
module OneDrive
module Util
using ServiceResultRefinements
class << self
def mime_type(json)
json.dig(:file, :mimeType) || (json.key?(:folder) ? "application/x-op-directory" : nil)
end
class << self
def mime_type(json)
json.dig(:file, :mimeType) || (json.key?(:folder) ? "application/x-op-directory" : nil)
end
def using_user_token(storage, user, &)
connection_manager = ::OAuthClients::ConnectionManager
.new(user:, configuration: storage.oauth_configuration)
def using_user_token(storage, user, &)
connection_manager = ::OAuthClients::ConnectionManager
.new(user:, configuration: storage.oauth_configuration)
connection_manager
.get_access_token
.match(
on_success: ->(token) do
connection_manager.request_with_token_refresh(token) { yield token }
end,
on_failure: ->(_) do
ServiceResult.failure(
result: :unauthorized,
errors: ::Storages::StorageError.new(
code: :unauthorized,
data: ::Storages::StorageErrorData.new(source: connection_manager),
log_message: "Query could not be created! No access token found!"
connection_manager
.get_access_token
.match(
on_success: ->(token) do
connection_manager.request_with_token_refresh(token) { yield token }
end,
on_failure: ->(_) do
ServiceResult.failure(
result: :unauthorized,
errors: StorageError.new(
code: :unauthorized,
data: StorageErrorData.new(source: connection_manager),
log_message: "Query could not be created! No access token found!"
)
)
end
)
end
def storage_error(response:, code:, source:)
data = StorageErrorData.new(source:, payload: response.json(symbolize_keys: true))
StorageError.new(code:, data:)
end
def join_uri_path(uri, *)
# We use `File.join` to ensure single `/` in between every part. This API will break if executed on a
# Windows context, as it used `\` as file separators. But we anticipate that OpenProject
# Server is not run on a Windows context.
# URI::join cannot be used, as it behaves very different for the path parts depending on trailing slashes.
File.join(uri.to_s, *)
end
def json_content_type
{ headers: { "Content-Type" => "application/json" } }
end
# rubocop:disable Metrics/AbcSize
def using_admin_token(storage)
oauth_client = storage.oauth_configuration.basic_rack_oauth_client
token_result =
begin
Rails.cache.fetch("storage.#{storage.id}.access_token", expires_in: 50.minutes) do
ServiceResult.success(result: oauth_client.access_token!(scope: "https://graph.microsoft.com/.default"))
end
rescue Rack::OAuth2::Client::Error => e
ServiceResult.failure(errors: ::Storages::StorageError.new(
code: :unauthorized,
data: StorageErrorData.new(source: self.class),
log_message: e.message
))
end
token_result.match(
on_success: ->(token) do
yield OpenProject.httpx.with(origin: storage.uri,
headers: { authorization: "Bearer #{token.access_token}",
accept: "application/json",
"content-type": "application/json" })
end,
on_failure: ->(errors) { ServiceResult.failure(result: :unauthorized, errors:) }
)
)
end
# rubocop:enable Metrics/AbcSize
def extract_location(parent_reference, file_name = "")
location = parent_reference[:path].gsub(/.*root:/, "")
appendix = file_name.blank? ? "" : "/#{file_name}"
location.empty? ? "/#{file_name}" : "#{location}#{appendix}"
end
end
)
end
def storage_error(response:, code:, source:)
data = ::Storages::StorageErrorData.new(source:, payload: response.json(symbolize_keys: true))
::Storages::StorageError.new(code:, data:)
end
def join_uri_path(uri, *)
# We use `File.join` to ensure single `/` in between every part. This API will break if executed on a
# Windows context, as it used `\` as file separators. But we anticipate that OpenProject
# Server is not run on a Windows context.
# URI::join cannot be used, as it behaves very different for the path parts depending on trailing slashes.
File.join(uri.to_s, *)
end
def json_content_type
{ headers: { "Content-Type" => "application/json" } }
end
def using_admin_token(storage)
oauth_client = storage.oauth_configuration.basic_rack_oauth_client
token_result = begin
Rails.cache.fetch("storage.#{storage.id}.access_token", expires_in: 50.minutes) do
ServiceResult.success(result: oauth_client.access_token!(scope: "https://graph.microsoft.com/.default"))
end
rescue Rack::OAuth2::Client::Error => e
ServiceResult.failure(errors: ::Storages::StorageError.new(
code: :unauthorized,
data: ::Storages::StorageErrorData.new(source: self.class),
log_message: e.message
))
end
token_result.match(
on_success: ->(token) do
yield OpenProject.httpx.with(origin: storage.uri,
headers: { authorization: "Bearer #{token.access_token}",
accept: "application/json",
"content-type": "application/json" })
end,
on_failure: ->(errors) { ServiceResult.failure(result: :unauthorized, errors:) }
)
end
def extract_location(parent_reference, file_name = "")
location = parent_reference[:path].gsub(/.*root:/, "")
appendix = file_name.blank? ? "" : "/#{file_name}"
location.empty? ? "/#{file_name}" : "#{location}#{appendix}"
end
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages
StorageFileId = Data.define(:id)
end
@@ -46,10 +46,10 @@ module Storages
def call
source_file_links = FileLink
.includes(:creator)
.where(storage: @source.storage,
container_id: @work_packages_map.keys,
container_type: "WorkPackage")
.includes(:creator)
.where(storage: @source.storage,
container_id: @work_packages_map.keys,
container_type: "WorkPackage")
with_locale_for(@user) do
if @source.project_folder_automatic?
@@ -62,6 +62,7 @@ module Storages
private
# rubocop:disable Metrics/AbcSize
def create_managed_file_links(source_file_links)
location_map = build_location_map(
source_files_info(source_file_links).result,
@@ -83,6 +84,9 @@ module Storages
end
end
# rubocop:enable Metrics/AbcSize
# rubocop:disable Metrics/AbcSize
def build_location_map(source_files, target_location_map)
# We need this due to inconsistencies of how we represent the File Path
target_location_map.transform_keys! { |key| key.starts_with?("/") ? key : "/#{key}" }
@@ -99,6 +103,8 @@ module Storages
end
end
# rubocop:enable Metrics/AbcSize
def auth_strategy
Peripherals::StorageInteraction::AuthenticationStrategies::OAuthUserToken
.strategy
@@ -114,7 +120,7 @@ module Storages
def target_files_map
Peripherals::Registry
.resolve("#{@source.storage.short_provider_type}.queries.folder_files_file_ids_deep_query")
.resolve("#{@source.storage.short_provider_type}.queries.folder_files_file_ids_deep")
.call(storage: @source.storage, folder: Peripherals::ParentFolder.new(@target.project_folder_location))
end
@@ -31,7 +31,7 @@
require "spec_helper"
require_module_spec_helper
RSpec.describe Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolderCommand, :vcr, :webmock do
RSpec.describe Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolderCommand, :webmock do
let(:user) { create(:user) }
let(:storage) do
create(:nextcloud_storage_with_local_connection, :as_not_automatically_managed, oauth_client_token_user: user)
@@ -40,14 +40,14 @@ RSpec.describe Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolde
Storages::Peripherals::StorageInteraction::AuthenticationStrategies::OAuthUserToken.strategy.with_user(user)
end
it_behaves_like "basic command setup"
it_behaves_like "create_folder_command: basic command setup"
context "when creating a folder in the root", vcr: "nextcloud/create_folder_root" do
let(:folder_name) { "Földer CreatedBy Çommand" }
let(:parent_location) { Storages::Peripherals::ParentFolder.new("/") }
let(:path) { "/#{folder_name}" }
it_behaves_like "successful folder creation"
it_behaves_like "create_folder_command: successful folder creation"
end
context "when creating a folder in a parent folder", vcr: "nextcloud/create_folder_parent" do
@@ -55,7 +55,7 @@ RSpec.describe Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolde
let(:parent_location) { Storages::Peripherals::ParentFolder.new("/Folder") }
let(:path) { "/Folder/#{folder_name}" }
it_behaves_like "successful folder creation"
it_behaves_like "create_folder_command: successful folder creation"
end
context "when creating a folder in a non-existing parent folder", vcr: "nextcloud/create_folder_parent_not_found" do
@@ -63,7 +63,7 @@ RSpec.describe Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolde
let(:parent_location) { Storages::Peripherals::ParentFolder.new("/DeathStar3") }
let(:error_source) { described_class }
it_behaves_like "parent not found"
it_behaves_like "create_folder_command: parent not found"
end
context "when folder already exists", vcr: "nextcloud/create_folder_already_exists" do
@@ -71,7 +71,7 @@ RSpec.describe Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolde
let(:parent_location) { Storages::Peripherals::ParentFolder.new("/") }
let(:error_source) { described_class }
it_behaves_like "folder already exists"
it_behaves_like "create_folder_command: folder already exists"
end
private
@@ -0,0 +1,88 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
require_module_spec_helper
RSpec.describe Storages::Peripherals::StorageInteraction::Nextcloud::FolderFilesFileIdsDeepQuery, :webmock do
let(:user) { create(:user) }
let(:storage) do
create(:nextcloud_storage_with_local_connection, :as_not_automatically_managed, oauth_client_token_user: user)
end
let(:auth_strategy) do
Storages::Peripherals::StorageInteraction::AuthenticationStrategies::OAuthUserToken.strategy.with_user(user)
end
it_behaves_like "folder_files_file_ids_deep_query: basic query setup"
context "with parent folder being root", vcr: "nextcloud/folder_files_file_ids_deep_query_root" do
let(:folder) { Storages::Peripherals::ParentFolder.new("/") }
let(:expected_ids) do
{
"/" => "2",
"/Folder with spaces" => "165",
"/Folder with spaces/New Requests" => "166",
"/Folder with spaces/New Requests/request_001.md" => "167",
"/Folder with spaces/New Requests/request_002.md" => "168",
"/Folder" => "169",
"/Folder/android-studio-2021.3.1.17-linux.tar.gz" => "267",
"/Folder/empty" => "172",
"/Folder/Ümlæûts" => "350",
"/Folder/Ümlæûts/Anrüchiges deutsches Dokument.docx" => "351",
"/Practical_guide_to_BAGGM_Digital.pdf" => "295",
"/Readme.md" => "268"
}
end
it_behaves_like "folder_files_file_ids_deep_query: successful query"
end
context "with a given parent folder", vcr: "nextcloud/folder_files_file_ids_deep_query_parent_folder" do
let(:folder) { Storages::Peripherals::ParentFolder.new("/Folder") }
let(:expected_ids) do
{
"/Folder" => "169",
"/Folder/android-studio-2021.3.1.17-linux.tar.gz" => "267",
"/Folder/empty" => "172",
"/Folder/Ümlæûts" => "350",
"/Folder/Ümlæûts/Anrüchiges deutsches Dokument.docx" => "351"
}
end
it_behaves_like "folder_files_file_ids_deep_query: successful query"
end
context "with not existent parent folder", vcr: "nextcloud/folder_files_file_ids_deep_query_invalid_parent" do
let(:folder) { Storages::Peripherals::ParentFolder.new("/I/just/made/that/up") }
let(:error_source) { Storages::Peripherals::StorageInteraction::Nextcloud::Internal::PropfindQuery }
it_behaves_like "folder_files_file_ids_deep_query: not found"
end
end
@@ -31,20 +31,20 @@
require "spec_helper"
require_module_spec_helper
RSpec.describe Storages::Peripherals::StorageInteraction::OneDrive::CreateFolderCommand, :vcr, :webmock do
RSpec.describe Storages::Peripherals::StorageInteraction::OneDrive::CreateFolderCommand, :webmock do
let(:storage) { create(:sharepoint_dev_drive_storage) }
let(:auth_strategy) do
Storages::Peripherals::StorageInteraction::AuthenticationStrategies::OAuthClientCredentials.strategy
end
it_behaves_like "basic command setup"
it_behaves_like "create_folder_command: basic command setup"
context "when creating a folder in the root", vcr: "one_drive/create_folder_root" do
let(:folder_name) { "Földer CreatedBy Çommand" }
let(:parent_location) { Storages::Peripherals::ParentFolder.new("/") }
let(:path) { "/#{folder_name}" }
it_behaves_like "successful folder creation"
it_behaves_like "create_folder_command: successful folder creation"
end
context "when creating a folder in a parent folder", vcr: "one_drive/create_folder_parent" do
@@ -52,7 +52,7 @@ RSpec.describe Storages::Peripherals::StorageInteraction::OneDrive::CreateFolder
let(:parent_location) { Storages::Peripherals::ParentFolder.new("01AZJL5PKU2WV3U3RKKFF2A7ZCWVBXRTEU") }
let(:path) { "/Folder with spaces/#{folder_name}" }
it_behaves_like "successful folder creation"
it_behaves_like "create_folder_command: successful folder creation"
end
context "when creating a folder in a non-existing parent folder", vcr: "one_drive/create_folder_parent_not_found" do
@@ -60,7 +60,7 @@ RSpec.describe Storages::Peripherals::StorageInteraction::OneDrive::CreateFolder
let(:parent_location) { Storages::Peripherals::ParentFolder.new("01AZJL5PKU2WV3U3RKKFF4A7ZCWVBXRTEU") }
let(:error_source) { described_class }
it_behaves_like "parent not found"
it_behaves_like "create_folder_command: parent not found"
end
context "when folder already exists", vcr: "one_drive/create_folder_already_exists" do
@@ -68,7 +68,7 @@ RSpec.describe Storages::Peripherals::StorageInteraction::OneDrive::CreateFolder
let(:parent_location) { Storages::Peripherals::ParentFolder.new("/") }
let(:error_source) { described_class }
it_behaves_like "folder already exists"
it_behaves_like "create_folder_command: folder already exists"
end
private
@@ -32,126 +32,66 @@ require "spec_helper"
require_module_spec_helper
RSpec.describe Storages::Peripherals::StorageInteraction::OneDrive::FolderFilesFileIdsDeepQuery, :webmock do
using Storages::Peripherals::ServiceResultRefinements
let(:storage) { create(:sharepoint_dev_drive_storage) }
let(:auth_strategy) do
Storages::Peripherals::StorageInteraction::AuthenticationStrategies::OAuthClientCredentials.strategy
end
let(:user) { create(:user) }
let(:storage) { create(:sharepoint_dev_drive_storage, oauth_client_token_user: user) }
let(:folder) { Storages::Peripherals::ParentFolder.new("/") }
it_behaves_like "folder_files_file_ids_deep_query: basic query setup"
describe "#call" do
it "responds with correct parameters" do
expect(described_class).to respond_to(:call)
method = described_class.method(:call)
expect(method.parameters).to contain_exactly(%i[keyreq storage], %i[keyreq folder])
context "with parent folder being root", vcr: "one_drive/folder_files_file_ids_deep_query_root" do
let(:folder) { Storages::Peripherals::ParentFolder.new("/") }
let(:expected_ids) do
{
"/" => "01AZJL5PN6Y2GOVW7725BZO354PWSELRRZ",
"/Folder with spaces" => "01AZJL5PKU2WV3U3RKKFF2A7ZCWVBXRTEU",
"/Folder with spaces/very empty folder" => "01AZJL5PMGEIRPHZPHRRH2NM3D734VIR7H",
"/Folder with spaces/wordle1.png" => "01AZJL5PPMSBBO3R2BIZHJFCELSW3RP7GN",
"/Folder with spaces/wordle2.png" => "01AZJL5PIIFUD6A765KBAIAEMYACAFB2WP",
"/Folder with spaces/wordle3.png" => "01AZJL5PL4AUJEU43CQZFJKN7BQPRP3BLF",
"/Folder" => "01AZJL5PMAXGDWAAKMEBALX4Q6GSN5BSBR",
"/Folder/Images" => "01AZJL5PMIF7ND3KH6FVDLZYP3E36ERFGI",
"/Folder/Subfolder" => "01AZJL5PPWP5UOATNRJJBYJG5TACDHEUAG",
"/Folder/Ümlæûts" => "01AZJL5PNQYF5NM3KWYNA3RJHJIB2XMMMB",
"/Folder/Document.docx" => "01AZJL5PJTICED3C5YSVAY6NWTBNA2XERU",
"/Folder/Sheet.xlsx" => "01AZJL5PLB7SH7633RMBHIH6KVMQRU4RJS",
"/Folder/Images/der_laufende.jpeg" => "01AZJL5PLZFCARRQIDFJF36UL2WTLXTNSY",
"/Folder/Images/written_in_stone.webp" => "01AZJL5PLNCKWYI752YBHYYJ6RBFZWOZ46",
"/Folder/Subfolder/NextcloudHub.md" => "01AZJL5PNCQCEBFI3N7JGZSX5AOX32Z3LA",
"/Folder/Subfolder/test.txt" => "01AZJL5PLOL2KZTJNVFBCJWFXYGYVBQVMZ",
"/Folder/Ümlæûts/Anrüchiges deutsches Dokument.docx" => "01AZJL5PNDURPQGKUSGFCJQJMNNWXKTHSE",
"/Permissions Folder" => "01AZJL5PN3LVLHH2RSZZDJ6ZFAD3OWSGYB"
}
end
context "with outbound requests successful" do
subject do
described_class.call(storage:, folder:).result
end
it_behaves_like "folder_files_file_ids_deep_query: successful query"
end
context "with parent folder being root", vcr: "one_drive/folder_files_file_ids_deep_query_root" do
it "returns the file id dictionary" do
expect(subject.transform_values(&:id))
.to eq({
"/" => "01AZJL5PN6Y2GOVW7725BZO354PWSELRRZ",
"/Folder with spaces" => "01AZJL5PKU2WV3U3RKKFF2A7ZCWVBXRTEU",
"/Folder with spaces/very empty folder" => "01AZJL5PMGEIRPHZPHRRH2NM3D734VIR7H",
"/Folder with spaces/wordle1.png" => "01AZJL5PPMSBBO3R2BIZHJFCELSW3RP7GN",
"/Folder with spaces/wordle2.png" => "01AZJL5PIIFUD6A765KBAIAEMYACAFB2WP",
"/Folder with spaces/wordle3.png" => "01AZJL5PL4AUJEU43CQZFJKN7BQPRP3BLF",
"/Folder" => "01AZJL5PMAXGDWAAKMEBALX4Q6GSN5BSBR",
"/Folder/Images" => "01AZJL5PMIF7ND3KH6FVDLZYP3E36ERFGI",
"/Folder/Subfolder" => "01AZJL5PPWP5UOATNRJJBYJG5TACDHEUAG",
"/Folder/Ümlæûts" => "01AZJL5PNQYF5NM3KWYNA3RJHJIB2XMMMB",
"/Folder/Document.docx" => "01AZJL5PJTICED3C5YSVAY6NWTBNA2XERU",
"/Folder/Sheet.xlsx" => "01AZJL5PLB7SH7633RMBHIH6KVMQRU4RJS",
"/Folder/Images/der_laufende.jpeg" => "01AZJL5PLZFCARRQIDFJF36UL2WTLXTNSY",
"/Folder/Images/written_in_stone.webp" => "01AZJL5PLNCKWYI752YBHYYJ6RBFZWOZ46",
"/Folder/Subfolder/NextcloudHub.md" => "01AZJL5PNCQCEBFI3N7JGZSX5AOX32Z3LA",
"/Folder/Subfolder/test.txt" => "01AZJL5PLOL2KZTJNVFBCJWFXYGYVBQVMZ",
"/Folder/Ümlæûts/Anrüchiges deutsches Dokument.docx" => "01AZJL5PNDURPQGKUSGFCJQJMNNWXKTHSE",
"/Permissions Folder" => "01AZJL5PN3LVLHH2RSZZDJ6ZFAD3OWSGYB"
})
end
end
context "with a given parent folder", vcr: "one_drive/folder_files_file_ids_deep_query_parent_folder" do
let(:folder) { Storages::Peripherals::ParentFolder.new("01AZJL5PMAXGDWAAKMEBALX4Q6GSN5BSBR") }
it "returns the file id dictionary" do
expect(subject.transform_values(&:id))
.to eq({
"/Folder" => "01AZJL5PMAXGDWAAKMEBALX4Q6GSN5BSBR",
"/Folder/Images" => "01AZJL5PMIF7ND3KH6FVDLZYP3E36ERFGI",
"/Folder/Subfolder" => "01AZJL5PPWP5UOATNRJJBYJG5TACDHEUAG",
"/Folder/Ümlæûts" => "01AZJL5PNQYF5NM3KWYNA3RJHJIB2XMMMB",
"/Folder/Document.docx" => "01AZJL5PJTICED3C5YSVAY6NWTBNA2XERU",
"/Folder/Sheet.xlsx" => "01AZJL5PLB7SH7633RMBHIH6KVMQRU4RJS",
"/Folder/Images/der_laufende.jpeg" => "01AZJL5PLZFCARRQIDFJF36UL2WTLXTNSY",
"/Folder/Images/written_in_stone.webp" => "01AZJL5PLNCKWYI752YBHYYJ6RBFZWOZ46",
"/Folder/Subfolder/NextcloudHub.md" => "01AZJL5PNCQCEBFI3N7JGZSX5AOX32Z3LA",
"/Folder/Subfolder/test.txt" => "01AZJL5PLOL2KZTJNVFBCJWFXYGYVBQVMZ",
"/Folder/Ümlæûts/Anrüchiges deutsches Dokument.docx" => "01AZJL5PNDURPQGKUSGFCJQJMNNWXKTHSE"
})
end
end
context "with a given parent folder", vcr: "one_drive/folder_files_file_ids_deep_query_parent_folder" do
let(:folder) { Storages::Peripherals::ParentFolder.new("01AZJL5PMAXGDWAAKMEBALX4Q6GSN5BSBR") }
let(:expected_ids) do
{
"/Folder" => "01AZJL5PMAXGDWAAKMEBALX4Q6GSN5BSBR",
"/Folder/Images" => "01AZJL5PMIF7ND3KH6FVDLZYP3E36ERFGI",
"/Folder/Subfolder" => "01AZJL5PPWP5UOATNRJJBYJG5TACDHEUAG",
"/Folder/Ümlæûts" => "01AZJL5PNQYF5NM3KWYNA3RJHJIB2XMMMB",
"/Folder/Document.docx" => "01AZJL5PJTICED3C5YSVAY6NWTBNA2XERU",
"/Folder/Sheet.xlsx" => "01AZJL5PLB7SH7633RMBHIH6KVMQRU4RJS",
"/Folder/Images/der_laufende.jpeg" => "01AZJL5PLZFCARRQIDFJF36UL2WTLXTNSY",
"/Folder/Images/written_in_stone.webp" => "01AZJL5PLNCKWYI752YBHYYJ6RBFZWOZ46",
"/Folder/Subfolder/NextcloudHub.md" => "01AZJL5PNCQCEBFI3N7JGZSX5AOX32Z3LA",
"/Folder/Subfolder/test.txt" => "01AZJL5PLOL2KZTJNVFBCJWFXYGYVBQVMZ",
"/Folder/Ümlæûts/Anrüchiges deutsches Dokument.docx" => "01AZJL5PNDURPQGKUSGFCJQJMNNWXKTHSE"
}
end
context "with not existent parent folder", vcr: "one_drive/folder_files_file_ids_deep_query_invalid_parent" do
let(:folder) { Storages::Peripherals::ParentFolder.new("/I/just/made/that/up") }
it_behaves_like "folder_files_file_ids_deep_query: successful query"
end
it "must return not found" do
result = described_class.call(storage:, folder:)
expect(result).to be_failure
expect(result.error_source).to be_a(described_class)
context "with not existent parent folder", vcr: "one_drive/folder_files_file_ids_deep_query_invalid_parent" do
let(:folder) { Storages::Peripherals::ParentFolder.new("/I/just/made/that/up") }
let(:error_source) { Storages::Peripherals::StorageInteraction::OneDrive::Internal::DriveItemQuery }
result.match(
on_failure: ->(error) { expect(error.code).to eq(:not_found) },
on_success: ->(file_infos) { fail "Expected failure, got #{file_infos}" }
)
end
end
context "with invalid oauth credentials", vcr: "one_drive/folder_files_file_ids_deep_query_invalid_credentials" do
before do
unauthorized_http = OpenProject.httpx.with(origin: storage.uri,
headers: { authorization: "Bearer YouShallNotPass",
accept: "application/json",
"content-type": "application/json" })
allow(Storages::Peripherals::StorageInteraction::OneDrive::Util)
.to receive(:using_admin_token)
.and_yield(unauthorized_http)
end
it "must return unauthorized" do
result = described_class.call(storage:, folder:)
expect(result).to be_failure
expect(result.error_source).to be_a(described_class)
result.match(
on_failure: ->(error) { expect(error.code).to eq(:unauthorized) },
on_success: ->(file_infos) { fail "Expected failure, got #{file_infos}" }
)
end
end
context "with network errors" do
before do
request = HTTPX::Request.new(:get, "https://my.timeout.org/")
httpx_double = class_double(HTTPX, get: HTTPX::ErrorResponse.new(request, "Timeout happens", {}))
allow(Storages::Peripherals::StorageInteraction::OneDrive::Util)
.to receive(:using_admin_token)
.and_yield(httpx_double)
end
it "must return an error with wrapped network error response" do
error = described_class.call(storage:, folder:)
expect(error).to be_failure
expect(error.result).to eq(:error)
expect(error.error_payload).to be_a(HTTPX::ErrorResponse)
end
end
it_behaves_like "folder_files_file_ids_deep_query: not found"
end
end
@@ -674,7 +674,7 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
expect(OpenProject.logger)
.to have_received(:warn)
.with(folder: "OpenProject",
command: Storages::Peripherals::StorageInteraction::Nextcloud::Internal::PropfindQuery,
command: Storages::Peripherals::StorageInteraction::Nextcloud::Internal::PropfindQueryLegacy,
message: "Outbound request destination not found",
data: { status: 404, body: "" })
end
@@ -0,0 +1,95 @@
---
http_interactions:
- request:
method: propfind
uri: https://nextcloud.local/remote.php/dav/files/admin/I/just/made/that/up
body:
encoding: UTF-8
string: |
<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<oc:fileid/>
<nc:acl-list/>
</d:prop>
</d:propfind>
headers:
Depth:
- infinity
Authorization:
- Bearer <BEARER TOKEN>
User-Agent:
- httpx.rb/1.2.4
Accept:
- "*/*"
Accept-Encoding:
- gzip, deflate
Content-Type:
- application/xml; charset=utf-8
Content-Length:
- '192'
response:
status:
code: 404
message: Not Found
headers:
Cache-Control:
- no-store, no-cache, must-revalidate
Content-Security-Policy:
- default-src 'none';
Content-Type:
- application/xml; charset=utf-8
Date:
- Mon, 29 Apr 2024 09:27:02 GMT
Dav:
- 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search,
nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
Expires:
- Thu, 19 Nov 1981 08:52:00 GMT
Pragma:
- no-cache
Referrer-Policy:
- no-referrer
Server:
- Apache/2.4.59 (Debian)
Set-Cookie:
- oc07ul6b4oaw=d5add640a32a32d41d02170d73dd30bd; path=/; secure; HttpOnly; SameSite=Lax,
oc_sessionPassphrase=eLXRRbs1vLi5xkH0WJYyVwQJqpjGro0c0Zg3nUyYr5VYgUqF9Bt2M8oZ9gNzG3r%2BetStTAiHiIzJoK9h3r4uj41hg%2Bp0rmSAbfcYTXntvL8N3gQIW%2BkkeMHfhya3npEJ;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=d5add640a32a32d41d02170d73dd30bd;
path=/; secure; HttpOnly; SameSite=Lax, __Host-nc_sameSiteCookielax=true;
path=/; httponly;secure; expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=lax,
__Host-nc_sameSiteCookiestrict=true; path=/; httponly;secure; expires=Fri,
31-Dec-2100 23:59:59 GMT; SameSite=strict, oc07ul6b4oaw=d5add640a32a32d41d02170d73dd30bd;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=d5add640a32a32d41d02170d73dd30bd;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=d5add640a32a32d41d02170d73dd30bd;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=d5add640a32a32d41d02170d73dd30bd;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=d5add640a32a32d41d02170d73dd30bd;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=d5add640a32a32d41d02170d73dd30bd;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=d5add640a32a32d41d02170d73dd30bd;
path=/; secure; HttpOnly; SameSite=Lax
Vary:
- Brief,Prefer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Powered-By:
- PHP/8.2.18
X-Robots-Tag:
- noindex, nofollow
X-Xss-Protection:
- 1; mode=block
Content-Length:
- '231'
body:
encoding: UTF-8
string: |
<?xml version="1.0" encoding="utf-8"?>
<d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns">
<s:exception>Sabre\DAV\Exception\NotFound</s:exception>
<s:message>File with name //I could not be located</s:message>
</d:error>
recorded_at: Mon, 29 Apr 2024 09:27:02 GMT
recorded_with: VCR 6.2.0
@@ -0,0 +1,98 @@
---
http_interactions:
- request:
method: propfind
uri: https://nextcloud.local/remote.php/dav/files/admin/Folder
body:
encoding: UTF-8
string: |
<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<oc:fileid/>
<nc:acl-list/>
</d:prop>
</d:propfind>
headers:
Depth:
- infinity
Authorization:
- Bearer <BEARER TOKEN>
User-Agent:
- httpx.rb/1.2.4
Accept:
- "*/*"
Accept-Encoding:
- gzip, deflate
Content-Type:
- application/xml; charset=utf-8
Content-Length:
- '192'
response:
status:
code: 207
message: Multi-Status
headers:
Cache-Control:
- no-store, no-cache, must-revalidate
Content-Encoding:
- gzip
Content-Security-Policy:
- default-src 'none';
Content-Type:
- application/xml; charset=utf-8
Date:
- Mon, 29 Apr 2024 09:27:02 GMT
Dav:
- 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search,
nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
Expires:
- Thu, 19 Nov 1981 08:52:00 GMT
Pragma:
- no-cache
Referrer-Policy:
- no-referrer
Server:
- Apache/2.4.59 (Debian)
Set-Cookie:
- oc07ul6b4oaw=bbc1b8b5d96bfb844ecb9f000d05ccf9; path=/; secure; HttpOnly; SameSite=Lax,
oc_sessionPassphrase=QGRWLsaUpaf5JsdI0cV162JoZvcVfhQVV61E6mpPh%2BvmQRlBwBWC%2BP42vMFWwOmkkWTTF9miI%2FBEoiqthA3UQ3ib0UiO40kKObozzqQBv5d%2FsnkiMF0pJJMnsdM8JWTT;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=bbc1b8b5d96bfb844ecb9f000d05ccf9;
path=/; secure; HttpOnly; SameSite=Lax, __Host-nc_sameSiteCookielax=true;
path=/; httponly;secure; expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=lax,
__Host-nc_sameSiteCookiestrict=true; path=/; httponly;secure; expires=Fri,
31-Dec-2100 23:59:59 GMT; SameSite=strict, oc07ul6b4oaw=bbc1b8b5d96bfb844ecb9f000d05ccf9;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=bbc1b8b5d96bfb844ecb9f000d05ccf9;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=bbc1b8b5d96bfb844ecb9f000d05ccf9;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=bbc1b8b5d96bfb844ecb9f000d05ccf9;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=bbc1b8b5d96bfb844ecb9f000d05ccf9;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=bbc1b8b5d96bfb844ecb9f000d05ccf9;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=bbc1b8b5d96bfb844ecb9f000d05ccf9;
path=/; secure; HttpOnly; SameSite=Lax
Vary:
- Brief,Prefer
X-Content-Type-Options:
- nosniff
X-Debug-Token:
- C2A5yYZ2GjWsVq5rexEF
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Powered-By:
- PHP/8.2.18
X-Request-Id:
- C2A5yYZ2GjWsVq5rexEF
X-Robots-Tag:
- noindex, nofollow
X-Xss-Protection:
- 1; mode=block
Content-Length:
- '366'
body:
encoding: UTF-8
string: |
<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"><d:response><d:href>/remote.php/dav/files/admin/Folder/</d:href><d:propstat><d:prop><oc:fileid>169</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder/android-studio-2021.3.1.17-linux.tar.gz</d:href><d:propstat><d:prop><oc:fileid>267</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder/empty/</d:href><d:propstat><d:prop><oc:fileid>172</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder/%c3%9cml%c3%a6%c3%bbts/</d:href><d:propstat><d:prop><oc:fileid>350</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder/%c3%9cml%c3%a6%c3%bbts/Anr%c3%bcchiges%20deutsches%20Dokument.docx</d:href><d:propstat><d:prop><oc:fileid>351</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>
recorded_at: Mon, 29 Apr 2024 09:27:02 GMT
recorded_with: VCR 6.2.0
@@ -0,0 +1,98 @@
---
http_interactions:
- request:
method: propfind
uri: https://nextcloud.local/remote.php/dav/files/admin/
body:
encoding: UTF-8
string: |
<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<oc:fileid/>
<nc:acl-list/>
</d:prop>
</d:propfind>
headers:
Depth:
- infinity
Authorization:
- Bearer <BEARER TOKEN>
User-Agent:
- httpx.rb/1.2.4
Accept:
- "*/*"
Accept-Encoding:
- gzip, deflate
Content-Type:
- application/xml; charset=utf-8
Content-Length:
- '192'
response:
status:
code: 207
message: Multi-Status
headers:
Cache-Control:
- no-store, no-cache, must-revalidate
Content-Encoding:
- gzip
Content-Security-Policy:
- default-src 'none';
Content-Type:
- application/xml; charset=utf-8
Date:
- Mon, 29 Apr 2024 09:21:37 GMT
Dav:
- 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search,
nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
Expires:
- Thu, 19 Nov 1981 08:52:00 GMT
Pragma:
- no-cache
Referrer-Policy:
- no-referrer
Server:
- Apache/2.4.59 (Debian)
Set-Cookie:
- oc07ul6b4oaw=dce80de6ee27126cc7f063c499bbf2d2; path=/; secure; HttpOnly; SameSite=Lax,
oc_sessionPassphrase=qHbGrRqzApLz65yOgbjo0F50qZOP7tl5seLjGa5MF5xAsfWUBS86U4TbnP6wBuwrqH1oAgAR19gOq6FYJvymPZW3tkMSKR9kS5YaIuyIWuqjif2txirDkqaymU7cOOVG;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=dce80de6ee27126cc7f063c499bbf2d2;
path=/; secure; HttpOnly; SameSite=Lax, __Host-nc_sameSiteCookielax=true;
path=/; httponly;secure; expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=lax,
__Host-nc_sameSiteCookiestrict=true; path=/; httponly;secure; expires=Fri,
31-Dec-2100 23:59:59 GMT; SameSite=strict, oc07ul6b4oaw=dce80de6ee27126cc7f063c499bbf2d2;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=dce80de6ee27126cc7f063c499bbf2d2;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=dce80de6ee27126cc7f063c499bbf2d2;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=dce80de6ee27126cc7f063c499bbf2d2;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=dce80de6ee27126cc7f063c499bbf2d2;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=dce80de6ee27126cc7f063c499bbf2d2;
path=/; secure; HttpOnly; SameSite=Lax, oc07ul6b4oaw=dce80de6ee27126cc7f063c499bbf2d2;
path=/; secure; HttpOnly; SameSite=Lax
Vary:
- Brief,Prefer
X-Content-Type-Options:
- nosniff
X-Debug-Token:
- 6Cb1TBf2Ullw34vWD3yf
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Powered-By:
- PHP/8.2.18
X-Request-Id:
- 6Cb1TBf2Ullw34vWD3yf
X-Robots-Tag:
- noindex, nofollow
X-Xss-Protection:
- 1; mode=block
Content-Length:
- '477'
body:
encoding: UTF-8
string: |
<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"><d:response><d:href>/remote.php/dav/files/admin/</d:href><d:propstat><d:prop><oc:fileid>2</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder/</d:href><d:propstat><d:prop><oc:fileid>169</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder/android-studio-2021.3.1.17-linux.tar.gz</d:href><d:propstat><d:prop><oc:fileid>267</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder/empty/</d:href><d:propstat><d:prop><oc:fileid>172</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder/%c3%9cml%c3%a6%c3%bbts/</d:href><d:propstat><d:prop><oc:fileid>350</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder/%c3%9cml%c3%a6%c3%bbts/Anr%c3%bcchiges%20deutsches%20Dokument.docx</d:href><d:propstat><d:prop><oc:fileid>351</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder%20with%20spaces/</d:href><d:propstat><d:prop><oc:fileid>165</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder%20with%20spaces/New%20Requests/</d:href><d:propstat><d:prop><oc:fileid>166</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder%20with%20spaces/New%20Requests/request_001.md</d:href><d:propstat><d:prop><oc:fileid>167</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Folder%20with%20spaces/New%20Requests/request_002.md</d:href><d:propstat><d:prop><oc:fileid>168</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Practical_guide_to_BAGGM_Digital.pdf</d:href><d:propstat><d:prop><oc:fileid>295</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response><d:response><d:href>/remote.php/dav/files/admin/Readme.md</d:href><d:propstat><d:prop><oc:fileid>268</oc:fileid></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:acl-list/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>
recorded_at: Mon, 29 Apr 2024 09:21:38 GMT
recorded_with: VCR 6.2.0
@@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
RSpec.shared_examples_for "basic command setup" do
RSpec.shared_examples_for "create_folder_command: basic command setup" do
it "is registered as commands.create_folder" do
expect(Storages::Peripherals::Registry
.resolve("#{storage.short_provider_type}.commands.create_folder")).to eq(described_class)
@@ -45,7 +45,7 @@ RSpec.shared_examples_for "basic command setup" do
end
end
RSpec.shared_examples_for "successful folder creation" do
RSpec.shared_examples_for "create_folder_command: successful folder creation" do
it "creates a folder" do
result = described_class.call(storage:, auth_strategy:, folder_name:, parent_location:)
@@ -60,7 +60,7 @@ RSpec.shared_examples_for "successful folder creation" do
end
end
RSpec.shared_examples_for "parent not found" do
RSpec.shared_examples_for "create_folder_command: parent not found" do
it "returns a failure" do
result = described_class.call(storage:, auth_strategy:, folder_name:, parent_location:)
@@ -72,7 +72,7 @@ RSpec.shared_examples_for "parent not found" do
end
end
RSpec.shared_examples_for "folder already exists" do
RSpec.shared_examples_for "create_folder_command: folder already exists" do
it "returns a failure" do
result = described_class.call(storage:, auth_strategy:, folder_name:, parent_location:)
@@ -0,0 +1,68 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
RSpec.shared_examples_for "folder_files_file_ids_deep_query: basic query setup" do
it "is registered as queries.folder_files_file_ids_deep" do
expect(Storages::Peripherals::Registry
.resolve("#{storage.short_provider_type}.queries.folder_files_file_ids_deep")).to eq(described_class)
end
it "responds to #call with correct parameters" do
expect(described_class).to respond_to(:call)
method = described_class.method(:call)
expect(method.parameters).to contain_exactly(%i[keyreq storage],
%i[keyreq auth_strategy],
%i[keyreq folder])
end
end
RSpec.shared_examples_for "folder_files_file_ids_deep_query: successful query" do
it "returns a map of locations to file ids" do
result = described_class.call(storage:, auth_strategy:, folder:)
expect(result).to be_success
response = result.result
expect(response.transform_values(&:id)).to eq(expected_ids)
end
end
RSpec.shared_examples_for "folder_files_file_ids_deep_query: not found" do
it "returns a failure" do
result = described_class.call(storage:, auth_strategy:, folder:)
expect(result).to be_failure
error = result.errors
expect(error.code).to eq(:not_found)
expect(error.data.source).to eq(error_source)
end
end
@@ -123,7 +123,7 @@ RSpec.describe Storages::CopyProjectFoldersJob, :job, :webmock, with_good_job_ba
describe "managed project folders" do
before do
Storages::Peripherals::Registry
.stub("#{storage.short_provider_type}.queries.folder_files_file_ids_deep_query", ->(storage:, folder:) {
.stub("#{storage.short_provider_type}.queries.folder_files_file_ids_deep", ->(storage:, folder:) {
ServiceResult.success(result: target_deep_file_ids)
})
@@ -177,7 +177,7 @@ RSpec.describe Storages::CopyProjectFoldersJob, :job, :webmock, with_good_job_ba
})
Storages::Peripherals::Registry
.stub("#{storage.short_provider_type}.queries.folder_files_file_ids_deep_query", ->(storage:, folder:) {
.stub("#{storage.short_provider_type}.queries.folder_files_file_ids_deep", ->(storage:, folder:) {
ServiceResult.success(result: target_deep_file_ids)
})