diff --git a/.engine_cart.yml b/.engine_cart.yml
deleted file mode 100644
index a2765c06..00000000
--- a/.engine_cart.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-# Skipping test because rails 5.1.0 generates with an old version of capybara
-rails_options: "--skip-test <%= '--skip-listen' if ENV.fetch('RAILS_VERSION', '') < '5.0' %>"
diff --git a/.rubocop.yml b/.rubocop.yml
index 236b075c..c45eca42 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -35,6 +35,7 @@ Metrics/ClassLength:
Max: 130
Exclude:
- 'lib/browse_everything/driver/google_drive.rb'
+ - 'lib/browse_everything/driver/sharepoint.rb'
Metrics/MethodLength:
Exclude:
@@ -79,6 +80,7 @@ Style/MixinUsage:
- 'spec/lib/browse_everything/driver/file_system_spec.rb'
- 'spec/lib/browse_everything/driver/google_drive_spec.rb'
- 'spec/lib/browse_everything/driver/s3_spec.rb'
+ - 'spec/lib/browse_everything/driver/sharepoint_spec.rb'
- 'spec/services/browser_factory_spec.rb'
Style/NumericLiterals:
diff --git a/README.md b/README.md
index 196af011..fc9964e2 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ Community Support: [,
[Google Drive](http://drive.google.com),
-[Box](http://www.box.com), [Amazon S3](https://aws.amazon.com/s3/),
+[Box](http://www.box.com), [Amazon S3](https://aws.amazon.com/s3/), [Sharepoint](https://www.microsoft.com/en-us/microsoft-365/sharepoint/collaboration)
and a server-side directory share.
The gem uses [OAuth](http://oauth.net/) to connect to a user's account and
@@ -108,19 +108,17 @@ _Until a Product Owner has been identified, we ask that you please direct all re
## Supported Ruby Releases
Currently, the following releases of Ruby are tested:
+- 3.3
+- 3.2
- 3.1
-- 3.0
-- 2.7
-- 2.6
## Supported Rails Releases
The supported Rail releases follow those specified by [the security policy of the Rails Community](https://rubyonrails.org/security/). As is the case with the supported Ruby releases, it is recommended that one upgrades from any Rails release no longer receiving security updates.
+- 8.0
+- 7.2
- 7.1
- 7.0
- 6.1
-- 6.0
-- 5.2
-- 5.1
## Installation
diff --git a/SharePoint.md b/SharePoint.md
new file mode 100644
index 00000000..7e9a294f
--- /dev/null
+++ b/SharePoint.md
@@ -0,0 +1,28 @@
+# Sharepoint Provider
+
+This provider will allow browse-everything to access Sharepoint on behalf of a specific user. It routes through the `/me/joinedTeams` and `/me/drives` Graph API endpoints. This will list Teams that the user belongs to and the user's personal drives. Within each Team it will expand to list any child drives or files that the user has permission to access.
+
+https://learn.microsoft.com/en-us/graph/auth-v2-user?tabs=http
+
+Prerequisite:
+ * App must be registered in the Entra Admin center to receive client_id, client_secret, and tenant_id.
+ * If using .default endpoint as your scope, you must register API permissions for your application. Minimum permissions:
+ * Files.Read
+ * Files.Read.All
+ * Files.Read.Selected
+ * offline_access
+ * openid
+ * profile
+ * Team.ReadBasic.All
+ * User.Read
+
+To use the sharepoint provider add the following to `config/browse_everything_providers.yml`:
+
+```
+sharepoint:
+ client_id: MyAppClientID
+ client_secret: MyAppClientSecret
+ tenant_id: MyAzureTenantID
+ redirect_uri: https://example.com/browse/connect
+ scope: offline_access https://graph.microsoft.com/.default
+```
diff --git a/app/controllers/browse_everything_controller.rb b/app/controllers/browse_everything_controller.rb
index 0a3cb3a3..b36c5821 100644
--- a/app/controllers/browse_everything_controller.rb
+++ b/app/controllers/browse_everything_controller.rb
@@ -120,7 +120,7 @@ def provider
# Hence, a Browser must be reinstantiated for each request using the state provided in the Rails session
# @return [BrowseEverything::Browser]
def browser
- BrowserFactory.build(session: session, url_options: url_options)
+ @browser ||= BrowserFactory.build(session: session, url_options: url_options)
end
helper_method :auth_link
@@ -129,4 +129,5 @@ def browser
helper_method :provider
helper_method :provider_name
helper_method :provider_contents
+ helper_method :reset_provider_session!
end
diff --git a/app/views/browse_everything/index.html.erb b/app/views/browse_everything/index.html.erb
index 09e72c2b..afdd61ec 100644
--- a/app/views/browse_everything/index.html.erb
+++ b/app/views/browse_everything/index.html.erb
@@ -15,7 +15,12 @@
<% if provider.present? %>
<% if provider.authorized? %>
- <%= render :partial => 'files' %>
+ <% begin %>
+ <%= render :partial => 'files' %>
+ <% rescue StandardError %>
+ <% reset_provider_session! %>
+ <%= render :partial => 'auth' %>
+ <% end %>
<% else %>
<%= render :partial => 'auth' %>
<% end %>
diff --git a/lib/browse_everything.rb b/lib/browse_everything.rb
index d69273eb..2b4da0cc 100644
--- a/lib/browse_everything.rb
+++ b/lib/browse_everything.rb
@@ -14,6 +14,7 @@ module Driver
autoload :FileSystem, 'browse_everything/driver/file_system'
autoload :GoogleDrive, 'browse_everything/driver/google_drive'
autoload :S3, 'browse_everything/driver/s3'
+ autoload :Sharepoint, 'browse_everything/driver/sharepoint'
# Intentionally require explicit require, as it has a non-declared dependency
# autoload :Dropbox, 'browse_everything/driver/dropbox'
@@ -41,6 +42,12 @@ module Google
end
end
+ module Auth
+ module Sharepoint
+ autoload :Session, 'browse_everything/auth/sharepoint/session'
+ end
+ end
+
class InitializationError < RuntimeError; end
class ConfigurationError < StandardError; end
class NotImplementedError < StandardError; end
diff --git a/lib/browse_everything/auth/google/credentials.rb b/lib/browse_everything/auth/google/credentials.rb
deleted file mode 100644
index 23c59e7e..00000000
--- a/lib/browse_everything/auth/google/credentials.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-require 'googleauth'
-
-# Object structuring the credentials retrieved for the Google API's
-module BrowseEverything
- module Auth
- module Google
- class Credentials < ::Google::Auth::UserRefreshCredentials
- # Ensures that every call to retrieve an access token does *not* require an HTTP request
- # @see Google::Auth::UserRefreshCredentials#fetch_access_token
- # @param options [Hash] the access token values
- def fetch_access_token(options = {})
- return build_token_hash if access_token
- super(options)
- end
-
- private
-
- # Structure a hash from existing access token values (usually cached within a Cookie)
- # @return [Hash]
- def build_token_hash
- { 'access_token' => access_token }
- end
- end
- end
- end
-end
diff --git a/lib/browse_everything/auth/sharepoint/session.rb b/lib/browse_everything/auth/sharepoint/session.rb
new file mode 100644
index 00000000..f2b034f7
--- /dev/null
+++ b/lib/browse_everything/auth/sharepoint/session.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'oauth2'
+
+# BrowseEverything OAuth2 session for
+# Sharepoint provider
+module BrowseEverything
+ module Auth
+ module Sharepoint
+ class Session
+ OAUTH2_URLS = {
+ site: 'https://login.microsoftonline.com'
+ }.freeze
+
+ def initialize(opts = {})
+ token_info = opts[:access_token]&.symbolize_keys
+
+ if opts[:client_id]
+ @oauth2_client = OAuth2::Client.new(opts[:client_id],
+ opts[:client_secret],
+ {
+ authorize_url: authorize_url(opts[:tenant_id]),
+ token_url: token_url(opts[:tenant_id]),
+ redirect_uri: opts[:redirect_uri],
+ scope: opts[:scope]
+ }.merge!(OAUTH2_URLS.dup))
+ return if token_info.blank?
+ @access_token = OAuth2::AccessToken.new(@oauth2_client,
+ token_info[:token],
+ {
+ refresh_token: token_info[:refresh_token],
+ expires_in: token_info[:expires_in]
+ })
+ end
+ end
+
+ def authorize_url(tenant_id)
+ tenant_id + "/oauth2/v2.0/authorize"
+ end
+
+ def token_url(tenant_id)
+ tenant_id + "/oauth2/v2.0/token"
+ end
+
+ def get_access_token(code)
+ @access_token = @oauth2_client.auth_code.get_token(code)
+ end
+
+ def refresh_token
+ @access_token = @access_token.refresh!
+ end
+ end
+ end
+ end
+end
diff --git a/lib/browse_everything/driver/google_drive.rb b/lib/browse_everything/driver/google_drive.rb
index ccb8c2b6..ca4e230f 100644
--- a/lib/browse_everything/driver/google_drive.rb
+++ b/lib/browse_everything/driver/google_drive.rb
@@ -32,7 +32,7 @@ def token=(value)
value = value.fetch('access_token') if value.is_a? Hash
# Restore the credentials if the access token string itself has been cached
- restore_credentials(value) if @credentials.nil?
+ @credentials = authorizer.get_credentials(user_id) || restore_credentials(value) if @credentials.nil?
super(value)
end
@@ -90,15 +90,64 @@ def list_files(drive, request_params, path: '')
@entries += list_files(drive, request_params, path: path) if request_params.page_token.present?
end
+ # Retrieve the drive details
+ # @param drive [Google::Apis::DriveV3::Drive] the Google Drive File
+ # @param path [String] path for the resource details (unused)
+ # @return [BrowseEverything::FileEntry] file entry for the resource node
+ def drive_details(drive)
+ BrowseEverything::FileEntry.new(
+ drive.id,
+ "#{key}:#{drive.id}",
+ drive.name,
+ 0,
+ Time.new,
+ true,
+ 'drive'
+ )
+ end
+
+ # Lists the drives accessible by a Google Drive context
+ # @param drive [Google::Apis::DriveV3::DriveService] the Google Drive context
+ # @return [Array] file entries for the drives
+ def list_drives(drive)
+ page_token = nil
+ drive.list_drives(fields: "nextPageToken,drives(name,id)", page_size: 100) do |drive_list, error|
+ # Raise an exception if there was an error Google API's
+ if error.present?
+ # In order to properly trigger reauthentication, the token must be cleared
+ # Additionally, the error is not automatically raised from the Google Client
+ @token = nil
+ raise error
+ end
+
+ @entries += drive_list.drives.map do |gdrive_file|
+ drive_details(gdrive_file)
+ end
+
+ page_token = drive_list.next_page_token
+ end
+
+ @entries += list_drives(drive) if page_token.present?
+ end
+
# Retrieve the files for any given resource on Google Drive
# @param path [String] the root or Folder path for which to list contents
# @return [Array] file entries for the path
def contents(path = '')
@entries = []
- drive_service.batch do |drive|
- request_params = Auth::Google::RequestParameters.new
- request_params.q += " and '#{path}' in parents " if path.present?
- list_files(drive, request_params, path: path)
+ if path.empty?
+ @entries << drive_details(Google::Apis::DriveV3::Drive.new(id: "root", name: "My Drive"))
+ @entries << drive_details(Google::Apis::DriveV3::Drive.new(id: "shared_drives", name: "Shared drives")) if drive_service.list_drives.drives.any?
+ elsif path == 'shared_drives'
+ drive_service.batch do |drive|
+ list_drives(drive)
+ end
+ else
+ drive_service.batch do |drive|
+ request_params = Auth::Google::RequestParameters.new
+ request_params.q += " and '#{path}' in parents "
+ list_files(drive, request_params, path: path)
+ end
end
@sorter.call(@entries)
@@ -108,7 +157,7 @@ def contents(path = '')
# @param id [String] identifier for the resource
# @return [Array] authorized link to the resource
def link_for(id)
- file = drive_service.get_file(id, fields: 'id, name, size')
+ file = drive_service.get_file(id, supports_all_drives: true, fields: 'id, name, size')
auth_header = { 'Authorization' => "Bearer #{credentials.access_token}" }
extras = {
auth_header: auth_header,
@@ -166,7 +215,7 @@ def authorizer
# This is *the* method which, passing an HTTP request, redeems an authorization code for an access token
# @return [String] a new access token
def authorize!
- @credentials = authorizer.get_credentials_from_code(user_id: user_id, code: code)
+ @credentials = authorizer.get_and_store_credentials_from_code(user_id: user_id, code: code)
@token = @credentials.access_token
@code = nil # The authorization code can only be redeemed for an access token once
@token
@@ -229,11 +278,11 @@ def download_url(id)
# @param access_token [String] the access token redeemed using an authorization code
# @return Credentials credentials restored from a cached access token
def restore_credentials(access_token)
- client = Auth::Google::Credentials.new
+ client = Google::Auth::UserRefreshCredentials.new
client.client_id = client_id.id
client.client_secret = client_id.secret
client.update_token!('access_token' => access_token)
- @credentials = client
+ client
end
end
end
diff --git a/lib/browse_everything/driver/sharepoint.rb b/lib/browse_everything/driver/sharepoint.rb
new file mode 100644
index 00000000..dfa070f5
--- /dev/null
+++ b/lib/browse_everything/driver/sharepoint.rb
@@ -0,0 +1,239 @@
+# frozen_string_literal: true
+
+require_relative 'authentication_factory'
+
+module BrowseEverything
+ module Driver
+ # Driver for accessing the MS-Graph API (https://learn.microsoft.com/en-us/graph/overview)
+ class Sharepoint < Base
+ class << self
+ attr_accessor :authentication_klass
+
+ def default_authentication_klass
+ BrowseEverything::Auth::Sharepoint::Session
+ end
+ end
+
+ # Constructor
+ # @param [Hash] config_values The configuration for the driver
+ def initialize(config_values)
+ self.class.authentication_klass ||= self.class.default_authentication_klass
+ super(config_values)
+ end
+
+ def icon
+ 'cloud'
+ end
+
+ # Validates the configuration for the Sharepoint provider
+ def validate_config
+ raise InitializationError, 'Sharepoint driver requires a :client_id argument' unless config[:client_id]
+ raise InitializationError, 'Sharepoint driver requires a :client_secret argument' unless config[:client_secret]
+ raise InitializationError, 'Sharepoint driver requires a :tenant_id argument' unless config[:tenant_id]
+ raise InitializationError, 'Sharepoint driver requires a :redirect_uri argument' unless config[:redirect_uri]
+ raise InitializationError, 'Sharepoint driver requires a :scope argument' unless config[:scope]
+ end
+
+ # Retrieves the file entry objects for a given path to MS-graph drive resource
+ # @param [String] id The id of the file or folder
+ # @return [Array]
+ def contents(id = '')
+ token_refresh if authorized?
+
+ folder = []
+ if id.empty?
+ # The metadata returned does not have anything identifiable as results being teams.
+ # To facilitate getting subsequent routes correct we add a teams identifier.
+ folder << teams.map { |t| t.merge!({ teams: true }) }
+ folder << drives
+ else
+ folder << items_by_id(id)
+ end
+
+ values = []
+
+ folder.flatten.each do |f|
+ # Entries in folder array should not have a value key.
+ # Skip entries that do to prevent blank folders in list.
+ next if f['value']
+ values << directory_entry(f)
+ end
+ @entries = values.compact
+
+ @sorter.call(@entries)
+ end
+
+ # @return [String]
+ # Authorization url that is used to request the initial access code from Sharepoint/Onedrive/365/etc
+ def auth_link(*_args)
+ Addressable::URI.parse("https://login.microsoftonline.com/#{config[:tenant_id]}/oauth2/v2.0/authorize?#{auth_query_string}")
+ end
+
+ # @return [Boolean]
+ def authorized?
+ @token.present?
+ end
+
+ def authorize!
+ return if @code.blank?
+ register_access_token(sharepoint_session.get_access_token(@code))
+ @code = nil
+ @token
+ end
+
+ def connect(params, _data, _url_options)
+ @code = params[:code]
+ authorize!
+ end
+
+ # @param [String] id The id of the file on MS graph drive
+ # @return [Array]
+ def link_for(id)
+ file = items_by_id(id)
+ extras = { file_name: file['name'], file_size: file['size'].to_i }
+ [download_url(file), extras]
+ end
+
+ private
+
+ def auth_query_string
+ query = []
+ base = config.slice('client_id', 'scope', 'redirect_uri')
+ base.each do |k, v|
+ query += ["#{k}=#{v}"]
+ end
+ query += ["response_type=code", @consent_refresh.present? ? "prompt=consent" : nil].compact
+
+ query.join('&')
+ end
+
+ def session
+ AuthenticationFactory.new(
+ self.class.authentication_klass,
+ client_id: config[:client_id],
+ client_secret: config[:client_secret],
+ tenant_id: config[:tenant_id],
+ scope: config[:scope],
+ redirect_uri: config[:redirect_uri],
+ code: @code.presence,
+ access_token: @token.presence
+ )
+ end
+
+ def authenticate
+ session.authenticate
+ end
+
+ def sharepoint_session
+ @sharepoint_session ||= authenticate
+ end
+
+ def token_refresh
+ return @token unless token_expired?
+
+ register_access_token(sharepoint_session.refresh_token)
+ end
+
+ # If there is an active session, {@token} will be set by {BrowseEverythingController} using data stored in the
+ # session.
+ #
+ # @param [OAuth2::AccessToken] access_token
+ def register_access_token(access_token)
+ @token = {
+ 'token' => access_token.token,
+ 'expires_in' => access_token.expires_in,
+ 'expires_at' => access_token.expires_at,
+ 'refresh_token' => access_token.refresh_token
+ }
+ end
+
+ def sharepoint_token
+ return unless @token
+ @token.fetch('token', nil)
+ end
+
+ def expiration_time
+ return unless @token
+ expires_at = @token.fetch('expires_at', nil)
+ # rubocop: disable Style/SafeNavigation
+ expires_at.nil? ? nil : expires_at.to_i
+ # rubocop: enable Style/SafeNavigation
+ end
+
+ def token_expired?
+ return true if expiration_time.nil?
+ Time.now.to_i > expiration_time
+ end
+
+ def sharepoint_request(sharepoint_uri)
+ @auth = "Bearer " + sharepoint_token
+
+ uri = URI.parse(sharepoint_uri)
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true if uri.scheme == 'https'
+
+ response = http.start do
+ request = Net::HTTP::Get.new(uri.request_uri, { 'Authorization' => @auth })
+ http.request(request)
+ end
+
+ parsed_response = JSON.parse(response.body)
+ # If permissions are changed, we need to redirect the user to the consent
+ # page so they can agree to the new permissions. Set flag for that here.
+ @consent_refresh = parsed_response.dig('error', 'message')&.include?('Missing scope permissions on the request.') ? true : false
+
+ parsed_response
+ end
+
+ # Constructs a BrowseEverything::FileEntry object for a Sharepoint file
+ # resource
+ # @param [String] file The ID of the file resource
+ # @return [BrowseEverything::File]
+ def directory_entry(file)
+ BrowseEverything::FileEntry.new(make_path(file),
+ [key, make_path(file)].join(':'),
+ file['displayName'] ? file['displayName'] : file['name'],
+ file['size'] ? file['size'] : nil,
+ file['lastModifiedDateTime'] ? Date.parse(file['lastModifiedDateTime']) : nil,
+ folder?(file))
+ end
+
+ # Derives a path from item (file or folder or drive) metadata
+ # that can be used in subsequent items_by_id calls
+ def make_path(file)
+ if file['parentReference'].present?
+ folder?(file) ? "#{file['parentReference']['driveId']}/items/#{file['id']}/children" : "#{file['parentReference']['driveId']}/items/#{file['id']}"
+ elsif file[:teams].present?
+ "#{file['id']}/drives"
+ else
+ "#{file['id']}/root/children"
+ end
+ end
+
+ def folder?(file)
+ file['file'].blank?
+ end
+
+ def teams
+ @teams ||= sharepoint_request("https://graph.microsoft.com/v1.0/me/joinedTeams?$select=id,displayName")['value']
+ end
+
+ def drives
+ @drives ||= sharepoint_request("https://graph.microsoft.com/v1.0/me/drives?$select=id,name,lastModifiedDateTime")['value']
+ end
+
+ def items_by_id(id)
+ item = if id.end_with?('drives')
+ sharepoint_request("https://graph.microsoft.com/v1.0/groups/#{id}")
+ else
+ sharepoint_request("https://graph.microsoft.com/v1.0/me/drives/#{id}")
+ end
+ item['value'].presence || item
+ end
+
+ def download_url(file)
+ file['@microsoft.graph.downloadUrl']
+ end
+ end
+ end
+end
diff --git a/spec/lib/browse_everything/auth/google/credentials_spec.rb b/spec/lib/browse_everything/auth/google/credentials_spec.rb
deleted file mode 100644
index 8461d910..00000000
--- a/spec/lib/browse_everything/auth/google/credentials_spec.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-describe BrowseEverything::Auth::Google::Credentials do
- subject(:credentials) { described_class.new }
-
- describe '#fetch_access_token' do
- let(:response) { double }
-
- before do
- WebMock.disable!
-
- allow(response).to receive(:status_code).and_return('200')
- allow(response).to receive(:body).and_return('{}')
- allow(response).to receive(:header).and_return(content_type: 'application/json')
-
- connection = instance_double(Faraday::Connection)
- allow(connection).to receive(:post).and_return(response)
- faraday = class_double('Faraday').as_stubbed_const(transfer_nested_constants: true)
- allow(faraday).to receive(:default_connection).and_return(connection)
- end
-
- context 'when an access has already been retrieved' do
- before do
- credentials.access_token = 'test-token'
- end
-
- it 'generates a Hash if an access token has already been set' do
- expect(credentials.fetch_access_token).to be_a Hash
- expect(credentials.fetch_access_token).to include('access_token' => 'test-token')
- end
- end
-
- it 'requests a new token from the OAuth provider' do
- expect(credentials.fetch_access_token).to be_a Hash
- expect(credentials.fetch_access_token).to eq({ "granted_scopes" => nil })
- end
-
- after do
- WebMock.enable!
- end
- end
-end
diff --git a/spec/lib/browse_everything/driver/google_drive_spec.rb b/spec/lib/browse_everything/driver/google_drive_spec.rb
index 507866c7..9918e020 100644
--- a/spec/lib/browse_everything/driver/google_drive_spec.rb
+++ b/spec/lib/browse_everything/driver/google_drive_spec.rb
@@ -101,56 +101,120 @@
let(:drive_service_class) { class_double(Google::Apis::DriveV3::DriveService).as_stubbed_const(transfer_nested_constants: true) }
let(:drive_service) { instance_double(Google::Apis::DriveV3::DriveService) }
- let(:file_list) { instance_double(Google::Apis::DriveV3::FileList) }
- let(:file1) { instance_double(Google::Apis::DriveV3::File) }
- let(:file2) { instance_double(Google::Apis::DriveV3::File) }
- let(:files) { [file1, file2] }
before do
- allow(file1).to receive(:id).and_return('asset-id2')
- allow(file2).to receive(:id).and_return('directory-id1')
- allow(file1).to receive(:name).and_return('asset-name2.pdf')
- allow(file2).to receive(:name).and_return('directory-name1')
- allow(file1).to receive(:size).and_return('891764')
- allow(file2).to receive(:size).and_return('0')
- allow(file1).to receive(:modified_time).and_return(Time.current)
- allow(file2).to receive(:modified_time).and_return(Time.current)
- allow(file1).to receive(:mime_type).and_return('application/pdf')
- allow(file2).to receive(:mime_type).and_return('application/vnd.google-apps.folder')
- allow(file_list).to receive(:files).and_return(files)
- allow(file_list).to receive(:next_page_token).and_return(nil)
- allow(drive_service).to receive(:list_files).and_yield(file_list, nil)
allow(drive_service).to receive(:batch).and_yield(drive_service)
allow(drive_service).to receive(:authorization=)
allow(drive_service).to receive(:tap).and_yield(drive_service).and_return(drive_service)
allow(drive_service_class).to receive(:new).and_return(drive_service)
end
- it 'retrieves files' do
- expect(contents).not_to be_empty
-
- expect(contents.first).to be_a BrowseEverything::FileEntry
- expect(contents.first.location).to eq 'google_drive:directory-id1'
- expect(contents.first.mtime).to be_a Time
- expect(contents.first.name).to eq 'directory-name1'
- expect(contents.first.size).to eq 0
- expect(contents.first.type).to eq 'directory'
-
- expect(contents.last).to be_a BrowseEverything::FileEntry
- expect(contents.last.location).to eq 'google_drive:asset-id2'
- expect(contents.last.mtime).to be_a Time
- expect(contents.last.name).to eq 'asset-name2.pdf'
- expect(contents.last.size).to eq 891764
- expect(contents.last.type).to eq 'application/pdf'
+ context 'drives' do
+ let(:drive_list) { instance_double(Google::Apis::DriveV3::DriveList) }
+ let(:drive1) { instance_double(Google::Apis::DriveV3::Drive) }
+ let(:drive2) { instance_double(Google::Apis::DriveV3::Drive) }
+ let(:drives) { [drive1, drive2] }
+
+ before do
+ allow(drive1).to receive(:id).and_return('drive-id1')
+ allow(drive2).to receive(:id).and_return('drive-id2')
+ allow(drive1).to receive(:name).and_return('drive-name1')
+ allow(drive2).to receive(:name).and_return('drive-name2')
+ allow(drive_list).to receive(:drives).and_return(drives)
+ allow(drive_list).to receive(:next_page_token).and_return(nil)
+ allow(drive_service).to receive(:list_drives).and_return(drive_list)
+ allow(drive_service).to receive(:list_drives).with({ fields: "nextPageToken,drives(name,id)", page_size: 100 }).and_yield(drive_list, nil)
+ end
+
+ it 'creates top level "My Drives" and "Shared Drives" directories' do
+ expect(contents).not_to be_empty
+
+ expect(contents.first).to be_a BrowseEverything::FileEntry
+ expect(contents.first.id).to eq 'root'
+ expect(contents.first.name).to eq 'My Drive'
+
+ expect(contents.second).to be_a BrowseEverything::FileEntry
+ expect(contents.second.id).to eq 'shared_drives'
+ expect(contents.second.name).to eq 'Shared drives'
+ end
+
+ context 'Shared Drives' do
+ subject(:contents) { driver.contents('shared_drives').to_a }
+
+ it 'retrieves a user\'s shared drives' do
+ expect(contents).not_to be_empty
+
+ expect(contents.first).to be_a BrowseEverything::FileEntry
+ expect(contents.first.id).to eq 'drive-id1'
+ expect(contents.first.name).to eq 'drive-name1'
+
+ expect(contents.second).to be_a BrowseEverything::FileEntry
+ expect(contents.second.id).to eq 'drive-id2'
+ expect(contents.second.name).to eq 'drive-name2'
+ end
+ end
+
+ context 'when an error is encountered while authenticating' do
+ before do
+ allow(drive_service).to receive(:list_drives).and_yield(drive_list, Google::Apis::Error.new('test error'))
+ end
+
+ it 'raises an exception' do
+ expect { driver.contents('shared_drives').to_a }.to raise_error(Google::Apis::Error, 'test error')
+ end
+ end
end
- context 'when an error is encountered while authenticating' do
+ context 'files' do
+ subject(:contents) { driver.contents('files').to_a }
+
+ let(:file_list) { instance_double(Google::Apis::DriveV3::FileList) }
+ let(:file1) { instance_double(Google::Apis::DriveV3::File) }
+ let(:file2) { instance_double(Google::Apis::DriveV3::File) }
+ let(:files) { [file1, file2] }
+
before do
- allow(drive_service).to receive(:list_files).and_yield(file_list, Google::Apis::Error.new('test error'))
+ allow(file1).to receive(:id).and_return('asset-id2')
+ allow(file2).to receive(:id).and_return('directory-id1')
+ allow(file1).to receive(:name).and_return('asset-name2.pdf')
+ allow(file2).to receive(:name).and_return('directory-name1')
+ allow(file1).to receive(:size).and_return('891764')
+ allow(file2).to receive(:size).and_return('0')
+ allow(file1).to receive(:modified_time).and_return(Time.current)
+ allow(file2).to receive(:modified_time).and_return(Time.current)
+ allow(file1).to receive(:mime_type).and_return('application/pdf')
+ allow(file2).to receive(:mime_type).and_return('application/vnd.google-apps.folder')
+ allow(file_list).to receive(:files).and_return(files)
+ allow(file_list).to receive(:next_page_token).and_return(nil)
+ allow(drive_service).to receive(:list_files).and_yield(file_list, nil)
end
- it 'raises an exception' do
- expect { driver.contents.to_a }.to raise_error(Google::Apis::Error, 'test error')
+ it 'retrieves files' do
+ expect(contents).not_to be_empty
+
+ expect(contents.first).to be_a BrowseEverything::FileEntry
+ expect(contents.first.location).to eq 'google_drive:directory-id1'
+ expect(contents.first.mtime).to be_a Time
+ expect(contents.first.name).to eq 'directory-name1'
+ expect(contents.first.size).to eq 0
+ expect(contents.first.type).to eq 'directory'
+
+ expect(contents.last).to be_a BrowseEverything::FileEntry
+ expect(contents.last.location).to eq 'google_drive:asset-id2'
+ expect(contents.last.mtime).to be_a Time
+ expect(contents.last.name).to eq 'asset-name2.pdf'
+ expect(contents.last.size).to eq 891764
+ expect(contents.last.type).to eq 'application/pdf'
+ end
+
+ context 'when an error is encountered while authenticating' do
+ before do
+ allow(drive_service).to receive(:list_files).and_yield(file_list, Google::Apis::Error.new('test error'))
+ end
+
+ it 'raises an exception' do
+ expect { driver.contents('files').to_a }.to raise_error(Google::Apis::Error, 'test error')
+ end
end
end
end
@@ -167,7 +231,7 @@
before do
stub_request(
- :get, "https://www.googleapis.com/drive/v3/files/asset-id2?fields=id,%20name,%20size"
+ :get, "https://www.googleapis.com/drive/v3/files/asset-id2?supportsAllDrives=true&fields=id,%20name,%20size"
).to_return(
body: file_response_body,
status: 200,
diff --git a/spec/lib/browse_everything/driver/sharepoint_spec.rb b/spec/lib/browse_everything/driver/sharepoint_spec.rb
new file mode 100644
index 00000000..55cc3eca
--- /dev/null
+++ b/spec/lib/browse_everything/driver/sharepoint_spec.rb
@@ -0,0 +1,268 @@
+# frozen_string_literal: true
+
+include BrowserConfigHelper
+
+describe BrowseEverything::Driver::Sharepoint do
+ let(:browser) { BrowseEverything::Browser.new(url_options) }
+ let(:provider) { browser.providers['sharepoint'] }
+ let(:config) do
+ {
+ client_id: 'CLIENTID', client_secret: 'CLIENTSECRET',
+ tenant_id: 'TENANTID', redirect_uri: 'http://example.com/browse/connect',
+ scope: 'offline_access https://graph.microsoft.com/.default'
+ }
+ end
+ let(:provider_yml) do
+ {
+ url_options: { port: '3000', protocol: 'http://', host: 'example.com' }
+ }.merge!(config)
+ end
+ let(:oauth_response_body) do
+ '{
+ "access_token": "access-token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "refresh_token": "refresh-token"
+ }'
+ end
+
+ before do
+ stub_configuration
+
+ stub_request(
+ :post, "https://login.microsoftonline.com/#{provider_yml[:tenant_id]}/oauth2/v2.0/token"
+ ).to_return(
+ body: oauth_response_body,
+ status: 200,
+ headers: {
+ 'Content-Type' => 'application/json; charset=UTF-8'
+ }
+ )
+ end
+
+ after do
+ unstub_configuration
+ end
+
+ describe 'simple properties' do
+ subject { provider }
+
+ its(:name) { is_expected.to eq('Sharepoint') }
+ its(:key) { is_expected.to eq('sharepoint') }
+ its(:icon) { is_expected.to eq('cloud') }
+ end
+
+ describe '#validate_config' do
+ it 'does not raise an error with a complete configuration' do
+ expect { described_class.new(config) }.to_not raise_error(BrowseEverything::InitializationError)
+ end
+
+ it 'raises an error with an incomplete configuration' do
+ expect { described_class.new({}) }.to raise_error(BrowseEverything::InitializationError)
+ end
+
+ it 'raises an error with a configuration without a client secret' do
+ expect { described_class.new(config.except(:client_secret)) }.to raise_error(BrowseEverything::InitializationError)
+ end
+
+ it 'raises an error with a configuration without a client id' do
+ expect { described_class.new(config.except(:client_id)) }.to raise_error(BrowseEverything::InitializationError)
+ end
+
+ it 'raises an error with a configuration without a tenant id' do
+ expect { described_class.new(config.except(:tenant_id)) }.to raise_error(BrowseEverything::InitializationError)
+ end
+
+ it 'raises an error with a configuration without a redirect uri' do
+ expect { described_class.new(config.except(:redirect_uri)) }.to raise_error(BrowseEverything::InitializationError)
+ end
+
+ it 'raises an error with a configuration without a scope' do
+ expect { described_class.new(config.except(:scope)) }.to raise_error(BrowseEverything::InitializationError)
+ end
+ end
+
+ context 'with a valid connection' do
+ let(:driver) { described_class.new(provider_yml) }
+
+ before do
+ driver.connect({ code: 'code' }, {}, nil)
+ allow(driver).to receive(:root_site).and_return('site')
+ end
+
+ describe '#authorized?' do
+ it 'is authorized' do
+ expect(driver.authorized?).to be true
+ end
+ end
+
+ describe '#contents' do
+ let(:team) do
+ {
+ 'value': [
+ {
+ 'id': 'team-id1',
+ 'displayName': 'Team'
+ }
+ ]
+ }.to_json
+ end
+ let(:drive) do
+ {
+ 'value': [
+ {
+ 'id': 'drive-id2',
+ 'name': 'Test OneDrive',
+ 'lastModifiedDateTime': Time.current
+ }
+ ]
+ }.to_json
+ end
+
+ before do
+ stub_request(
+ :get, "https://graph.microsoft.com/v1.0/me/joinedTeams?$select=id,displayName"
+ ).to_return(
+ body: team,
+ status: 200,
+ headers: {}
+ )
+
+ stub_request(
+ :get, "https://graph.microsoft.com/v1.0/me/drives?$select=id,name,lastModifiedDateTime"
+ ).to_return(
+ body: drive,
+ status: 200,
+ headers: {}
+ )
+ end
+
+ context 'without id' do
+ subject(:contents) { driver.contents.to_a }
+
+ it 'retrieves all top level sites and drives' do
+ expect(contents).not_to be_empty
+
+ expect(contents.first).to be_a BrowseEverything::FileEntry
+ expect(contents.first.location).to eq 'sharepoint:team-id1/drives'
+ expect(contents.first.mtime).to eq nil
+ expect(contents.first.name).to eq 'Team'
+ expect(contents.first.size).to eq nil
+ expect(contents.first.type).to eq 'application/x-directory'
+
+ expect(contents.last).to be_a BrowseEverything::FileEntry
+ expect(contents.last.location).to eq 'sharepoint:drive-id2/root/children'
+ expect(contents.last.mtime).to be_a Date
+ expect(contents.last.name).to eq 'Test OneDrive'
+ expect(contents.last.size).to eq nil
+ expect(contents.last.type).to eq 'application/x-directory'
+ end
+ end
+
+ context 'with id' do
+ subject(:contents) { driver.contents('drive-id2/root/children').to_a }
+ let(:file) do
+ {
+ 'value': [
+ {
+ 'id': 'asset-id3',
+ 'name': 'asset-name3.pdf',
+ 'size': 5336,
+ 'lastModifiedDateTime': Time.current,
+ 'parentReference': {
+ 'driveId': 'drive-id2',
+ 'driveType': 'business',
+ 'id': 'asset-id3'
+ },
+ 'file': {
+ 'mimeType': 'application/pdf'
+ },
+ '@microsoft.graph.downloadUrl': 'http://microsoft-download.com'
+ }
+ ]
+ }.to_json
+ end
+
+ before do
+ stub_request(
+ :get, 'https://graph.microsoft.com/v1.0/me/drives/drive-id2/root/children'
+ ).to_return(
+ body: file,
+ status: 200,
+ headers: {}
+ )
+ end
+
+ it 'retrieves files' do
+ expect(contents.first).to be_a BrowseEverything::FileEntry
+ expect(contents.first.location).to eq 'sharepoint:drive-id2/items/asset-id3'
+ expect(contents.first.mtime).to be_a Date
+ expect(contents.first.name).to eq 'asset-name3.pdf'
+ expect(contents.first.size).to eq 5336
+ expect(contents.first.type).to eq 'application/pdf'
+ end
+
+ it 'does not retrieve higher level items' do
+ expect(contents.length).to eq 1
+ end
+ end
+ end
+
+ describe '#link_for' do
+ subject(:link) { driver.link_for('drive-id2/items/asset-id3') }
+ let(:file) do
+ {
+ 'id': 'asset-id3',
+ 'name': 'asset-name3.pdf',
+ 'size': 5336,
+ 'lastModifiedDateTime': Time.current,
+ 'parentReference': {
+ 'driveId': 'drive-id2',
+ 'driveType': 'business',
+ 'id': 'asset-id3'
+ },
+ 'file': {
+ 'mimeType': 'application/pdf'
+ },
+ '@microsoft.graph.downloadUrl': 'http://microsoft-download.com/asset-id3'
+ }.to_json
+ end
+
+ before do
+ stub_request(
+ :get, 'https://graph.microsoft.com/v1.0/me/drives/drive-id2/items/asset-id3'
+ ).to_return(
+ body: file,
+ status: 200,
+ headers: {}
+ )
+ end
+
+ it 'generates the link for a Sharepoint asset' do
+ expect(link).to be_an Array
+ expect(link.first).to eq 'http://microsoft-download.com/asset-id3'
+ expect(link.last).to be_a Hash
+ expect(link.last).to include file_name: 'asset-name3.pdf'
+ expect(link.last).to include file_size: 5336
+ end
+ end
+
+ describe '#auth_link' do
+ subject(:uri) { driver.auth_link }
+
+ it 'exposes the authorization endpoint URI' do
+ expect(uri).to be_a Addressable::URI
+ expect(uri.normalize.to_s).to eq 'https://login.microsoftonline.com/TENANTID/oauth2/v2.0/authorize?client_id=CLIENTID&scope=offline_access%20https://graph.microsoft.com/.default&redirect_uri=http://example.com/browse/connect&response_type=code'
+ end
+
+ context 'when permissions have changed' do
+ before { driver.instance_variable_set(:@consent_refresh, 'true') }
+
+ it 'includes "prompt=consent" in the auth_link' do
+ expect(uri).to be_a Addressable::URI
+ expect(uri.normalize.to_s).to eq 'https://login.microsoftonline.com/TENANTID/oauth2/v2.0/authorize?client_id=CLIENTID&scope=offline_access%20https://graph.microsoft.com/.default&redirect_uri=http://example.com/browse/connect&response_type=code&prompt=consent'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 7e23978e..96a4f4e8 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -63,6 +63,13 @@ def stub_configuration
app_secret: 'S3AppSecret',
bucket: 's3.bucket',
region: 'us-east-1'
+ },
+ 'sharepoint' => {
+ client_id: 'SharepointClientID',
+ client_secret: 'SharepointClientSecret',
+ tenant_id: 'SharepointTenantID',
+ redirect_uri: 'http://example.com/browse/connect',
+ scope: 'offline_access https://graph.microsoft.com/.default'
})
end