From ecbc9a0d6b4b933df0c57757d0d258b811a417ee Mon Sep 17 00:00:00 2001 From: Justin Coyne Date: Sat, 4 Nov 2023 09:35:11 -0500 Subject: [PATCH] Allow using cocina json as the data source This is based on a global feature flag, so we can enable this via shared configs for testing --- .rubocop_todo.yml | 42 ++- app/models/cocina_ability.rb | 119 +++++++ app/models/cocina_rights.rb | 24 ++ app/models/purl.rb | 34 +- app/models/stacks_file.rb | 2 +- app/models/stacks_image.rb | 2 +- app/models/stacks_media_stream.rb | 3 +- app/models/stacks_rights.rb | 42 ++- config/settings.yml | 1 + spec/abilities/cocina_ability_spec.rb | 484 ++++++++++++++++++++++++++ 10 files changed, 725 insertions(+), 28 deletions(-) create mode 100644 app/models/cocina_ability.rb create mode 100644 app/models/cocina_rights.rb create mode 100644 spec/abilities/cocina_ability_spec.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 066f81a9..3a130953 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2023-11-03 20:55:41 UTC using RuboCop version 1.57.2. +# on 2023-11-08 15:54:52 UTC using RuboCop version 1.57.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -15,22 +15,27 @@ Layout/MultilineMethodCallBraceLayout: - 'spec/routing/file_routing_spec.rb' - 'spec/routing/media_routing_spec.rb' -# Offense count: 7 +# Offense count: 8 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 22 + Max: 65 -# Offense count: 2 +# Offense count: 3 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/CyclomaticComplexity: - Max: 10 + Max: 13 # Offense count: 13 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: - Max: 25 + Max: 50 -# Offense count: 35 +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/PerceivedComplexity: + Max: 13 + +# Offense count: 31 RSpec/AnyInstance: Exclude: - 'spec/controllers/media_controller_spec.rb' @@ -43,7 +48,7 @@ RSpec/AnyInstance: - 'spec/requests/iiif_spec.rb' - 'spec/requests/media_auth_request_spec.rb' -# Offense count: 89 +# Offense count: 78 # Configuration parameters: Prefixes, AllowedPatterns. # Prefixes: when, with, without RSpec/ContextWording: @@ -80,7 +85,7 @@ RSpec/EmptyLineAfterExampleGroup: - 'spec/controllers/legacy_image_service_controller_spec.rb' - 'spec/requests/file_auth_request_spec.rb' -# Offense count: 37 +# Offense count: 32 # This cop supports safe autocorrection (--autocorrect). RSpec/EmptyLineAfterFinalLet: Exclude: @@ -159,11 +164,11 @@ RSpec/MessageSpies: - 'spec/controllers/file_controller_spec.rb' - 'spec/controllers/media_controller_spec.rb' -# Offense count: 67 +# Offense count: 66 RSpec/MultipleExpectations: Max: 12 -# Offense count: 81 +# Offense count: 80 # Configuration parameters: EnforcedStyle, IgnoreSharedExamples. # SupportedStyles: always, named_only RSpec/NamedSubject: @@ -183,7 +188,7 @@ RSpec/NamedSubject: - 'spec/services/iiif_metadata_service_spec.rb' - 'spec/services/media_authentication_json_spec.rb' -# Offense count: 66 +# Offense count: 54 # Configuration parameters: AllowedGroups. RSpec/NestedGroups: Max: 6 @@ -217,6 +222,7 @@ RSpec/VerifiedDoubles: - 'spec/services/media_authentication_json_spec.rb' # Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: slashes, arguments Rails/FilePath: @@ -246,7 +252,7 @@ Style/HashAsLastArrayItem: Exclude: - 'app/controllers/object_controller.rb' -# Offense count: 18 +# Offense count: 13 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: PreferredDelimiters. Style/PercentLiteralDelimiters: @@ -266,17 +272,9 @@ Style/StringConcatenation: - 'spec/controllers/object_controller_spec.rb' - 'spec/models/stacks_media_token_spec.rb' -# Offense count: 4 +# Offense count: 3 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: MinSize. # SupportedStyles: percent, brackets Style/SymbolArray: EnforcedStyle: brackets - -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: AllowMethodsWithArguments, AllowedMethods, AllowedPatterns, AllowComments. -# AllowedMethods: define_method -Style/SymbolProc: - Exclude: - - 'app/controllers/webauth_controller.rb' diff --git a/app/models/cocina_ability.rb b/app/models/cocina_ability.rb new file mode 100644 index 00000000..b07803a1 --- /dev/null +++ b/app/models/cocina_ability.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +## +# User authentication +class CocinaAbility + include CanCan::Ability + + def initialize(user) + # Define abilities for the passed in user here. For example: + # + user ||= User.new # guest user (not logged in) + # if user.admin? + # can :manage, :all + # else + # can :read, :all + # end + # + # The first argument to `can` is the action you are giving the user + # permission to do. + # If you pass :manage it will apply to every action. Other common actions + # here are :read, :create, :update and :destroy. + # + # The second argument is the resource the user can perform the action on. + # If you pass :all, it will apply to every resource. Otherwise, pass a Ruby + # class of the resource. + # + # The third argument is an optional hash of conditions to further filter the + # objects. For example, here the user can only update published articles. + # can :update, Article, :published => true + # + # The block argument takes as a parameter an instance of the object for which + # permission is being checked. If the block returns true, the user is granted that + # ability, otherwise the user is denied that ability. The block is only evaluated + # for instances of objects, *not* for classes. If a class is passed to a `can?` or a + # `cannot?` that's defined by a block, that check will *always* grant permission. + # + # See the wiki for details: + # https://github.com/CanCanCommunity/cancancan/wiki/Defining-Abilities + # https://github.com/CanCanCommunity/cancancan/wiki/Defining-Abilities-with-Blocks + + # NOTE: the below ability definitions which reference StacksFile also implicitly + # cover StacksImage and StacksMediaStream, and any other subclasses of StacksFile. + + downloadable_models = [StacksFile, StacksImage] + access_models = downloadable_models + [StacksMediaStream] + + can :download, downloadable_models do |f| + f.cocina_rights.download == 'world' + end + + can [:access], access_models do |f| + f.cocina_rights.view == 'world' + end + + if user.stanford? + can :download, downloadable_models do |f| + f.cocina_rights.download == 'stanford' + end + + can [:access], access_models do |f| + f.cocina_rights.view == 'stanford' + end + end + + if user.locations.present? + can :download, downloadable_models do |f| + next unless f.cocina_rights.download == 'location-based' + + user.locations.include?(f.cocina_rights.location) + end + + can [:access], access_models do |f| + user.locations.any? do |_location| + next unless f.cocina_rights.view == 'location-based' + + user.locations.include?(f.cocina_rights.location) + end + end + end + + if user.cdl_tokens.present? + # TODO: Actually check if the CDL object is downloadable + # can [:download, :read], models do |f| + # ... + # end + + can [:access], access_models do |f| + next unless f.cocina_rights.controlled_digital_lending? + + user.cdl_tokens.any? { |payload| payload['aud'] == f.id } + end + end + + cannot :download, RestrictedImage + + # These are called when checking to see if the image response should be served + can [:download, :read], Projection do |projection| + can?(:download, projection.image) + end + + can [:download, :read], Projection do |projection| + # Allow access to tile or thumbnail-sized requests for an accessible image + (projection.tile? || projection.thumbnail?) && can?(:access, projection.image) + end + + can :access, Projection do |projection| + can?(:access, projection.image) + end + + can :read, Projection do |projection| + # Allow access to thumbnail-sized projections of a declared (or implicit) thumbnail for the object; + # note that because this is implicit, we do not check rightsMetadata permissions. + projection.thumbnail? && projection.object_thumbnail? + end + + alias_action :stream, to: :access + can :read_metadata, StacksImage + end +end diff --git a/app/models/cocina_rights.rb b/app/models/cocina_rights.rb new file mode 100644 index 00000000..76647742 --- /dev/null +++ b/app/models/cocina_rights.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# The rights derived from cocina +class CocinaRights + def initialize(file_rights) + @file_rights = file_rights + end + + def download + @file_rights['download'] + end + + def view + @file_rights['view'] + end + + def controlled_digital_lending? + @file_rights['controlledDigitalLending'] + end + + def location + @file_rights['location'] + end +end diff --git a/app/models/purl.rb b/app/models/purl.rb index 1ced8d65..88c6cd62 100644 --- a/app/models/purl.rb +++ b/app/models/purl.rb @@ -11,7 +11,7 @@ def self.instance end class << self - delegate :public_xml, :files, :barcode, to: :instance + delegate :public_xml, :public_json, :files, :barcode, to: :instance end # TODO: was etag a valid key? @@ -26,9 +26,35 @@ def public_xml(druid) end end - def files(druid) + def public_json(druid) + Rails.cache.fetch("purl/#{druid}.json", expires_in: 10.minutes) do + benchmark "Fetching public json for #{druid}" do + response = Faraday.get(public_json_url(druid)) + raise Purl::Exception, response.status unless response.success? + + JSON.parse(response.body) + end + end + end + + def files(druid, &block) return to_enum(:files, druid) unless block_given? + Settings.features.cocina ? files_from_json(druid, &block) : files_from_xml(druid, &block) + end + + def files_from_json(druid) + doc = public_json(druid) + + doc.dig('structural', 'contains').each do |fileset| + fileset.dig('structural', 'contains').each do |file| + file = StacksFile.new(id: druid, file_name: file['filename']) + yield file + end + end + end + + def files_from_xml(druid) doc = Nokogiri::XML.parse(public_xml(druid)) doc.xpath('//contentMetadata/resource').each do |resource| @@ -56,6 +82,10 @@ def public_xml_url(druid) Settings.purl.url + "#{druid}.xml" end + def public_json_url(druid) + "#{Settings.purl.url}#{druid}.json" + end + def logger Rails.logger end diff --git a/app/models/stacks_file.rb b/app/models/stacks_file.rb index ee4c75de..993bada1 100644 --- a/app/models/stacks_file.rb +++ b/app/models/stacks_file.rb @@ -46,5 +46,5 @@ def druid_parts def stacks_rights @stacks_rights ||= StacksRights.new(id:, file_name:) end - delegate :rights, to: :stacks_rights + delegate :rights, :cocina_rights, to: :stacks_rights end diff --git a/app/models/stacks_image.rb b/app/models/stacks_image.rb index b6cadcbb..836a18fa 100644 --- a/app/models/stacks_image.rb +++ b/app/models/stacks_image.rb @@ -62,6 +62,6 @@ def info_service def stacks_rights @stacks_rights ||= StacksRights.new(id:, file_name:) end - delegate :rights, :maybe_downloadable?, :object_thumbnail?, + delegate :rights, :cocina_rights, :maybe_downloadable?, :object_thumbnail?, :stanford_restricted?, :restricted_by_location?, :cdl_restricted?, to: :stacks_rights end diff --git a/app/models/stacks_media_stream.rb b/app/models/stacks_media_stream.rb index 670d91fe..d536c624 100644 --- a/app/models/stacks_media_stream.rb +++ b/app/models/stacks_media_stream.rb @@ -17,6 +17,7 @@ def file def stacks_rights @stacks_rights ||= StacksRights.new(id:, file_name:) end - delegate :rights, :restricted_by_location?, :stanford_restricted?, :embargoed?, + + delegate :rights, :cocina_rights, :restricted_by_location?, :stanford_restricted?, :embargoed?, :embargo_release_date, to: :stacks_rights end diff --git a/app/models/stacks_rights.rb b/app/models/stacks_rights.rb index 90863ac1..c9bfe206 100644 --- a/app/models/stacks_rights.rb +++ b/app/models/stacks_rights.rb @@ -5,6 +5,8 @@ class StacksRights attr_reader :id, :file_name + THUMBNAIL_MIME_TYPE = 'image/jp2' + def initialize(id:, file_name:) @id = id @file_name = file_name @@ -36,6 +38,10 @@ def restricted_by_location? delegate :embargoed?, :embargo_release_date, to: :rights def object_thumbnail? + use_json? ? cocina_thumbnail? : xml_thumbnail? + end + + def xml_thumbnail? doc = Nokogiri::XML.parse(public_xml) thumb_element = doc.xpath('//thumb') @@ -47,12 +53,46 @@ def object_thumbnail? end end + # Based on implementation of ThumbnailService in DSA + def cocina_thumbnail? + thumbnail_file = public_json.dig('structural', 'contains') + .lazy.flat_map { |file_set| file_set.dig('structural', 'contains') } + .find { |file| file['hasMimeType'] == THUMBNAIL_MIME_TYPE } + thumbnail_file == cocina_file + end + + def use_json? + Settings.features.cocina + end + def rights - @rights ||= Dor::RightsAuth.parse(rights_xml) + use_json? ? cocina_rights : dor_auth_rights + end + + def dor_auth_rights + @dor_auth_rights ||= Dor::RightsAuth.parse(rights_xml) + end + + def cocina_rights + @cocina_rights ||= CocinaRights.new(cocina_file['access']) end private + def cocina_file + @cocina_file ||= find_file + end + + def find_file + public_json.dig('structural', 'contains') + .lazy.flat_map { |file_set| file_set.dig('structural', 'contains') } + .find { |file| file['filename'] == file_name } || raise("File not found '#{file_name}'") + end + + def public_json + @public_json ||= Purl.public_json(id) + end + def rights_xml public_xml end diff --git a/config/settings.yml b/config/settings.yml index c4205a7a..aeddf981 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -45,6 +45,7 @@ cors: allow_origin_url: 'https://embed.stanford.edu' features: streaming_media: true + cocina: false token: default_expiry_time: <%= 1.hour %> diff --git a/spec/abilities/cocina_ability_spec.rb b/spec/abilities/cocina_ability_spec.rb new file mode 100644 index 00000000..18ad56b7 --- /dev/null +++ b/spec/abilities/cocina_ability_spec.rb @@ -0,0 +1,484 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'cancan/matchers' + +RSpec.describe CocinaAbility, type: :model do + subject(:ability) { described_class.new(user) } + + before do + allow(Settings.features).to receive(:cocina).and_return(true) + allow(Purl).to receive(:public_json).and_return(public_json) + allow(image).to receive_messages(image_width: 11_957, image_height: 15_227) + end + + let(:user) { nil } + + let(:public_json) do + { + 'structural' => { + 'contains' => [ + { + 'structural' => { + 'contains' => files + } + } + ] + } + } + end + let(:files) do + [file_json, image_json, media_json] + end + + let(:file_json) do + { + 'filename' => 'file.csv', + 'access' => access, + 'hasMimeType' => 'text/csv' + } + end + + let(:image_json) do + { + 'filename' => 'image.jpg', + 'access' => access, + 'hasMimeType' => 'image/jp2' + } + end + + let(:media_json) do + { + 'filename' => 'movie.mp4', + 'access' => access, + 'hasMimeType' => 'video/mp4' + } + end + + let(:file) do + StacksFile.new(id: 'xxxxxxx', file_name: 'file.csv') + end + let(:image) do + StacksImage.new(id: 'yx350pf4616', file_name: 'image.jpg') + end + let(:media) do + StacksMediaStream.new(id: 'xxxxxxx', file_name: 'movie.mp4') + end + + let(:thumbnail_transformation) { IIIF::Image::OptionDecoder.decode(region: 'full', size: '!400,400') } + let(:thumbnail) { Projection.new(image, thumbnail_transformation) } + let(:best_fit_thumbnail_transformation) { IIIF::Image::OptionDecoder.decode(region: 'full', size: '!600,500') } + let(:best_fit_thumbnail) { Projection.new(image, best_fit_thumbnail_transformation) } + let(:square_transformation) { IIIF::Image::OptionDecoder.decode(region: 'square', size: '!400,400') } + let(:square_thumbnail) { Projection.new(image, square_transformation) } + let(:tile_transformation) { IIIF::Image::OptionDecoder.decode(region: '0,0,100,100', size: '256,256') } + let(:tile) { Projection.new(image, tile_transformation) } + + let(:big_transform) { IIIF::Image::OptionDecoder.decode(region: 'full', size: '748,') } + let(:big_image) { Projection.new(image, big_transform) } + + context 'with a stanford webauth user' do + let(:user) { User.new(id: 'a', webauth_user: true, ldap_groups: %w[stanford:stanford]) } + + context 'with a world-readable file' do + let(:access) do + { + 'view' => 'world', + 'download' => 'world' + } + end + + it { is_expected.to be_able_to(:download, file) } + it { is_expected.to be_able_to(:download, image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + it { is_expected.to be_able_to(:read, big_image) } + end + + context 'with a stanford-only file' do + let(:access) do + { + 'view' => 'stanford', + 'download' => 'stanford' + } + end + + it { is_expected.to be_able_to(:download, file) } + it { is_expected.to be_able_to(:download, image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + it { is_expected.to be_able_to(:read, big_image) } + end + + context 'with read rights but not download' do + let(:access) do + { + 'view' => 'stanford', + 'download' => 'none' + } + end + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.not_to be_able_to(:read, big_image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, best_fit_thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with a file with no read access' do + let(:access) do + { + 'view' => 'none', + 'download' => 'none' + } + end + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.not_to be_able_to(:read, tile) } + it { is_expected.not_to be_able_to(:stream, media) } + it { is_expected.not_to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + end + + context 'with a non-stanford webauth user' do + let(:user) { User.new(id: 'a', webauth_user: true, ldap_groups: %w[stanford:sponsored]) } + + context 'with a world-readable file' do + let(:access) do + { + 'view' => 'world', + 'download' => 'world' + } + end + + it { is_expected.to be_able_to(:download, file) } + it { is_expected.to be_able_to(:download, image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with a stanford-only file' do + let(:access) do + { + 'view' => 'stanford', + 'download' => 'stanford' + } + end + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.not_to be_able_to(:read, tile) } + it { is_expected.not_to be_able_to(:stream, media) } + it { is_expected.not_to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with a stanford-only file that is not the thumbnail' do + let(:access) do + { + 'view' => 'stanford', + 'download' => 'stanford' + } + end + let(:true_thumbnail) do + { + 'filename' => 'x/y.jpg', + 'access' => access, + 'hasMimeType' => 'image/jp2' + } + end + let(:files) do + [file_json, true_thumbnail, image_json, media_json] + end + + it { is_expected.not_to be_able_to(:read, thumbnail) } + it { is_expected.not_to be_able_to(:read, square_thumbnail) } + end + + context 'with a stanford-only file that is the first image in an object without an explicit thumbnail' do + let(:access) do + { + 'view' => 'stanford', + 'download' => 'stanford' + } + end + + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with read rights but not download' do + let(:access) do + { + 'view' => 'stanford', + 'download' => 'none' + } + end + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.not_to be_able_to(:read, tile) } + it { is_expected.not_to be_able_to(:stream, media) } + it { is_expected.not_to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + end + + context 'with a no-download file that is not the thumbnail' do + let(:access) do + { + 'view' => 'world', + 'download' => 'none' + } + end + let(:true_thumbnail) do + { + 'filename' => 'x/y.jpg', + 'access' => access, + 'hasMimeType' => 'image/jp2' + } + end + let(:files) do + [file_json, true_thumbnail, image_json, media_json] + end + + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with location-based access restrictions' do + let(:access) do + { + 'view' => 'location-based', + 'download' => 'location-based', + 'location' => 'location1' + } + end + + context 'with an anonymous user from a configured location' do + let(:user) { User.new(ip_address: 'ip.address2') } + + it { is_expected.to be_able_to(:download, file) } + it { is_expected.to be_able_to(:download, image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with an anonymous user not in the configured location' do + let(:user) { User.new(ip_address: 'some.unknown.ip') } + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.not_to be_able_to(:read, tile) } + it { is_expected.not_to be_able_to(:stream, media) } + it { is_expected.not_to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with media that allows read but not download' do + let(:access) do + { + 'view' => 'location-based', + 'download' => 'none', + 'location' => 'location1' + } + end + + context 'with an anonymous user from a configured location' do + let(:user) { User.new(ip_address: 'ip.address2') } + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with an anonymous user not in the configured location' do + let(:user) { User.new(ip_address: 'some.unknown.ip') } + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.not_to be_able_to(:read, tile) } + it { is_expected.not_to be_able_to(:stream, media) } + it { is_expected.not_to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + end + end + + context 'with an anonymous user' do + context 'with a world-readable file' do + let(:access) do + { + 'view' => 'world', + 'download' => 'world' + } + end + + it { is_expected.to be_able_to(:download, file) } + it { is_expected.to be_able_to(:download, image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with a stanford-only file' do + let(:access) do + { + 'view' => 'stanford', + 'download' => 'stanford' + } + end + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.not_to be_able_to(:read, tile) } + it { is_expected.not_to be_able_to(:stream, media) } + it { is_expected.not_to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with a an unreadable file' do + let(:access) do + { + 'view' => 'none', + 'download' => 'none' + } + end + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.not_to be_able_to(:read, tile) } + it { is_expected.not_to be_able_to(:stream, media) } + it { is_expected.not_to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with read rights but not download' do + let(:access) do + { + 'view' => 'world', + 'download' => 'none' + } + end + + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + end + + context 'with world (no-download), and full access for stanford users' do + let(:access) do + { + 'view' => 'world', + 'download' => 'stanford' + } + end + + context 'with an anonymous user' do + it { is_expected.not_to be_able_to(:download, file) } + it { is_expected.not_to be_able_to(:download, image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + + context 'with a stanford webauth user' do + let(:user) { User.new(id: 'a', webauth_user: true, ldap_groups: %w[stanford:stanford]) } + + it { is_expected.to be_able_to(:download, file) } + it { is_expected.to be_able_to(:download, image) } + it { is_expected.to be_able_to(:read, tile) } + it { is_expected.to be_able_to(:stream, media) } + it { is_expected.to be_able_to(:access, file) } + it { is_expected.to be_able_to(:read_metadata, image) } + it { is_expected.to be_able_to(:read, thumbnail) } + it { is_expected.to be_able_to(:read, square_thumbnail) } + end + end + + describe 'for an object with CDL rights' do + let(:user) do + User.new(id: 'a', webauth_user: true, ldap_groups: %w[stanford:stanford], jwt_tokens:) + end + let(:jwt_tokens) { [] } + let(:access) do + { + 'view' => 'none', + 'download' => 'none', + 'controlledDigitalLending' => true + } + end + + it { is_expected.not_to be_able_to(:access, image) } + it { is_expected.not_to be_able_to(:access, file) } + + context 'with a Stanford user with a checkout JWT token' do + let(:jwt_tokens) do + [ + JWT.encode( + { aud: image.id, sub: 'a', exp: (Time.zone.now + 1.hour).to_i }, + Settings.cdl.jwt.secret, + Settings.cdl.jwt.algorithm + ) + ] + end + + it { is_expected.to be_able_to(:access, image) } + it { is_expected.not_to be_able_to(:access, file) } + end + end +end