diff --git a/.travis.yml b/.travis.yml index 5b4a59e5..81d45a43 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,9 @@ rvm: - 2.5 - 2.6 +env: + - RACK_ENV=test RAILS_ENV=test + branches: only: - master @@ -17,6 +20,7 @@ before_install: before_script: - bundle exec rake checkpoint:migrate - bundle exec rake keycard:migrate + - bin/rails db:migrate script: - bundle exec rspec diff --git a/app/attributes/deposit_status_type.rb b/app/attributes/deposit_status_type.rb new file mode 100644 index 00000000..22621286 --- /dev/null +++ b/app/attributes/deposit_status_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class DepositStatusType < ActiveRecord::Type::Value + def cast(value) + super(value.to_s) + end + + def deserialize(value) + Chipmunk::DepositStatus.new(value) + end + + def serialize(deposit_status) + deposit_status.to_s + end + +end diff --git a/app/controllers/v2/artifacts_controller.rb b/app/controllers/v2/artifacts_controller.rb new file mode 100644 index 00000000..5c6d3852 --- /dev/null +++ b/app/controllers/v2/artifacts_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module V2 + class ArtifactsController < ResourceController + + collection_policy ArtifactsPolicy + + def self.of_any_type + AnyArtifact.new + end + + def show + @artifact = Artifact.find(params[:id]) + render json: @artifact, status: 200 + end + + def create + collection_policy.new(current_user).authorize! :new? + # We don't explicitly check for :save? permissions + + if (duplicate = Artifact.find_by(id: params[:id])) + resource_policy.new(current_user, duplicate).authorize! :show? + head 303, location: v2_artifact_path(duplicate) + else + @artifact = new_artifact(params) + if @artifact.valid? + @artifact.save! + render json: @artifact, status: 201, location: v2_artifact_path(@artifact) + else + render json: @artifact.errors, status: :unprocessable_entity + end + end + end + + private + + def new_artifact(params) + Artifact.new( + id: params[:id], + user: current_user, + storage_format: params[:storage_format], + content_type: params[:content_type] + ) + end + + end +end diff --git a/app/controllers/v2/deposits_controller.rb b/app/controllers/v2/deposits_controller.rb new file mode 100644 index 00000000..0edc8304 --- /dev/null +++ b/app/controllers/v2/deposits_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module V2 + class DepositsController < ResourceController + + def create + # TODO policy check + @deposit = Deposit.create!( + artifact: Artifact.find(params[:artifact_id]), + user: current_user, + status: :started + ) + # TODO: don't pick a specific template + render "v2/deposits/show", status: 201, location: v2_deposit_path(@deposit) + end + + def ready + # TODO policy check + @deposit = Deposit.find(params[:id]) + case @deposit.status + when Deposit.statuses[:started] + @deposit.update!(status: Deposit.statuses[:ingesting]) + FinishDepositJob.perform_later(@deposit) + render json: @deposit, status: 200 + when Deposit.statuses[:ingesting] + render json: @deposit, status: 200 + else # :completed, :failed, :cancelled + head 422 # TODO think about this response + end + end + + end +end diff --git a/app/controllers/v2/revisions_controller.rb b/app/controllers/v2/revisions_controller.rb new file mode 100644 index 00000000..ac6fd783 --- /dev/null +++ b/app/controllers/v2/revisions_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module V2 + class RevisionsController < ResourceController + + end +end diff --git a/app/jobs/bag_move_job.rb b/app/jobs/bag_move_job.rb index b2e3471f..29f93222 100644 --- a/app/jobs/bag_move_job.rb +++ b/app/jobs/bag_move_job.rb @@ -30,28 +30,28 @@ def ingest! end def validate - package.valid_for_ingest?(errors) + result = Services.validation.validate(package) + errors.concat result.errors + result.valid? end def move_bag source = incoming_storage.for(package) - package_storage.write(package, source) + package_storage.write(package, source) do |volume, storage_path| + package.storage_volume = volume.name + package.storage_path = storage_path + end end def record_success queue_item.transaction do - queue_item.status = :done - queue_item.save! + queue_item.success! package.save! end end def record_failure - queue_item.transaction do - queue_item.error = errors.join("\n\n") - queue_item.status = :failed - queue_item.save! - end + queue_item.fail!(errors) end def log_exception(exception) diff --git a/app/jobs/finish_deposit_job.rb b/app/jobs/finish_deposit_job.rb new file mode 100644 index 00000000..98f8b1d4 --- /dev/null +++ b/app/jobs/finish_deposit_job.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class FinishDepositJob < ApplicationJob + def perform(deposit) + Chipmunk::FinishDeposit.new(deposit).run + end +end diff --git a/app/models/artifact.rb b/app/models/artifact.rb new file mode 100644 index 00000000..3f5a90a7 --- /dev/null +++ b/app/models/artifact.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class Artifact < ApplicationRecord + + class AnyArtifact + def to_resources + Artifact.content_types.map {|t| Checkpoint::Resource::AllOfType.new(t) } + end + + def resource_type + "Artifact" + end + + def resource_id + Checkpoint::Resource::ALL + end + end + + def self.resource_types + content_types + end + + def self.content_types + Rails.application.config.validation["bagger_profile"].keys + + Rails.application.config.validation["external"].keys + end + + def self.of_any_type + AnyArtifact.new + end + + alias_method :identifier, :id + + # Each artifact belongs to a single user + belongs_to :user + # Deposits are collections of zero or more revisions + has_many :revisions + # Revisions are added to artifacts via deposits + has_many :deposits + + validates :id, presence: true, + format: { with: Services.uuid_format, + message: "must be a valid v4 uuid." } + + validates :user, presence: true + validates :storage_format, presence: true # TODO this is a controlled vocabulary + validates :content_type, presence: true # TODO this is a controlled vocabulary + + def stored? + revisions.any? + end + +end diff --git a/app/models/deposit.rb b/app/models/deposit.rb new file mode 100644 index 00000000..6c0f5a0d --- /dev/null +++ b/app/models/deposit.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Deposit < ApplicationRecord + + # Each deposit is created by a single user + belongs_to :user + # A deposit is an attempt to append a revision to a single artifact + belongs_to :artifact + + # TODO Could not get the attributes api to work with a custom type + enum status: { + started: "started", + canceled: "cancelled", + ingesting: "ingesting", + failed: "failed", + completed: "completed" + } + + validates :user, presence: true + validates :artifact, presence: true + validates :status, presence: true # TODO this is a controlled vocabulary + + def identifier + id.to_s + end + + def username + user.username + end + + def content_type + artifact.content_type + end + + def storage_format + artifact.storage_format + end + + def complete! + update!(status: "completed") + end + + def fail!(errors) + update!(status: "failed", error: errors.join("\n")) + end + + def upload_link + Services.incoming_storage.upload_link(self) + end + +end diff --git a/app/models/package.rb b/app/models/package.rb index b04bfecd..5ce149ca 100644 --- a/app/models/package.rb +++ b/app/models/package.rb @@ -14,17 +14,29 @@ class Package < ApplicationRecord def to_param bag_id end + alias_method :identifier, :to_param validates :bag_id, presence: true, length: { minimum: 6 } validates :user_id, presence: true validates :external_id, presence: true - validates :format, presence: true + validates :format, presence: true, format: /bag/ # Declare the policy class to use for authz def self.policy_class PackagePolicy end + # Rails overrides the format param on requests, so we rename this to storage_format. + # This alias is for backwards compatibility + # alias_method :storage_format, :format + def storage_format + format + end + + def username + user.username + end + def upload_link Services.incoming_storage.upload_link(self) end @@ -40,39 +52,6 @@ def storage_location storage_path end - def storage_location= - raise "storage_location is not writable; use storage_volume and storage_path" - end - - # TODO: This is nasty... but the storage factory checks that the package is stored, - # so we have to make the storage proxy manually here. Once the ingest and preservation - # responsibilities are clarified, this will fall out. See PFDR-184. - def valid_for_ingest?(errors = []) - if stored? - errors << "Package #{bag_id} is already stored" - elsif format != Chipmunk::Bag.format - errors << "Package #{bag_id} has invalid format: #{format}" - elsif !incoming_storage.include?(self) - errors << "Bag #{bag_id} does not exist in incoming storage." - end - - return false unless errors.empty? - - Chipmunk::Bag::Validator.new(self, errors, incoming_storage.for(self)).valid? - end - - def external_validation_cmd - ext_cmd = Rails.application.config.validation["external"][content_type.to_s] - return unless ext_cmd - - path = incoming_storage.for(self).path - [ext_cmd, external_id, path].join(" ") - end - - def bagger_profile - Rails.application.config.validation["bagger_profile"][content_type.to_s] - end - def resource_type content_type end diff --git a/app/models/queue_item.rb b/app/models/queue_item.rb index 1dcc2a1e..0bdca2bb 100644 --- a/app/models/queue_item.rb +++ b/app/models/queue_item.rb @@ -19,6 +19,14 @@ def user package.user end + def success! + update!(status: :done) + end + + def fail!(errors) + update!(status: :failed, error: errors.join("\n\n")) + end + scope :for_package, ->(package_id) { where(package_id: package_id) unless package_id.blank? } scope :for_packages, ->(package_scope) { joins(:package).merge(package_scope) } end diff --git a/app/models/revision.rb b/app/models/revision.rb new file mode 100644 index 00000000..8a50a9b8 --- /dev/null +++ b/app/models/revision.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Revision < ApplicationRecord + belongs_to :artifact + belongs_to :deposit +end diff --git a/app/policies/artifacts_policy.rb b/app/policies/artifacts_policy.rb new file mode 100644 index 00000000..7f66bd85 --- /dev/null +++ b/app/policies/artifacts_policy.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ArtifactsPolicy < CollectionPolicy + def index? + can?(:show, Artifact.of_any_type) + end + + def new? + can?(:save, Artifact.of_any_type) + end + + def base_scope + Artifact.all + end + + def resolve + # Via the role map resolver, a user has access to: + # * All artifacts, if the user is an administrator + # * Artifacts of content types for which the user is a content manager + # * Artifacts of content types for which the user is authorized viewer + # * Any specific artifacts for which the user is granted access + ViewableResources.for(user, scope) + end +end diff --git a/app/views/v2/artifacts/_artifact.json.jbuilder b/app/views/v2/artifacts/_artifact.json.jbuilder new file mode 100644 index 00000000..11194aec --- /dev/null +++ b/app/views/v2/artifacts/_artifact.json.jbuilder @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# TODO: consider returning paths or urls +json.id artifact.id +json.user artifact.user.username +json.content_type artifact.content_type +json.created_at artifact.created_at.to_formatted_s(:default) +json.updated_at artifact.updated_at.to_formatted_s(:default) diff --git a/app/views/v2/artifacts/create.json.jbuilder b/app/views/v2/artifacts/create.json.jbuilder new file mode 100644 index 00000000..5adbbde3 --- /dev/null +++ b/app/views/v2/artifacts/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "v2/artifacts/artifact", expand: true, artifact: @artifact diff --git a/app/views/v2/artifacts/index.json.jbuilder b/app/views/v2/artifacts/index.json.jbuilder new file mode 100644 index 00000000..ca0d54c9 --- /dev/null +++ b/app/views/v2/artifacts/index.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "v2/artifacts/artifact", expand: false, collection: @artifacts, as: :artifact diff --git a/app/views/v2/artifacts/show.json.jbuilder b/app/views/v2/artifacts/show.json.jbuilder new file mode 100644 index 00000000..5adbbde3 --- /dev/null +++ b/app/views/v2/artifacts/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "v2/artifacts/artifact", expand: true, artifact: @artifact diff --git a/app/views/v2/deposits/_deposit.json.jbuilder b/app/views/v2/deposits/_deposit.json.jbuilder new file mode 100644 index 00000000..d06ea938 --- /dev/null +++ b/app/views/v2/deposits/_deposit.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# TODO: consider returning paths or urls +json.id deposit.id +json.artifact deposit.artifact_id +json.user deposit.user.username +json.status deposit.status +json.upload_link deposit.upload_link +json.created_at deposit.created_at.to_formatted_s(:default) +json.updated_at deposit.updated_at.to_formatted_s(:default) diff --git a/app/views/v2/deposits/create.json.jbuilder b/app/views/v2/deposits/create.json.jbuilder new file mode 100644 index 00000000..ed993be4 --- /dev/null +++ b/app/views/v2/deposits/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "v2/deposits/deposit", expand: true, deposit: @deposit diff --git a/app/views/v2/deposits/index.json.jbuilder b/app/views/v2/deposits/index.json.jbuilder new file mode 100644 index 00000000..f8bffb12 --- /dev/null +++ b/app/views/v2/deposits/index.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "v2/deposits/deposit", expand: false, collection: @deposits, as: :deposit diff --git a/app/views/v2/deposits/show.json.jbuilder b/app/views/v2/deposits/show.json.jbuilder new file mode 100644 index 00000000..ed993be4 --- /dev/null +++ b/app/views/v2/deposits/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "v2/deposits/deposit", expand: true, deposit: @deposit diff --git a/config/initializers/services.rb b/config/initializers/services.rb index 720a34ad..7143417e 100644 --- a/config/initializers/services.rb +++ b/config/initializers/services.rb @@ -31,47 +31,40 @@ def assign_db(lhs, rhs) end end -Services = Canister.new # TODO: consult the environment-specific configuration for a set of volumes -# TODO: Separate normal and test contexts -if Rails.env.test? - Services.register(:incoming_storage) do - Chipmunk::IncomingStorage.new( - volume: Chipmunk::Volume.new( - name: "incoming", - package_type: Chipmunk::Bag, - root_path: Chipmunk.config.upload.upload_path - ), - paths: Chipmunk::UploadPath.new("/"), - links: Chipmunk::UploadPath.new(Chipmunk.config.upload["rsync_point"]) - ) - end - Services.register(:storage) do - Chipmunk::PackageStorage.new(volumes: [ - Chipmunk::Volume.new(name: "test", package_type: Chipmunk::Bag, root_path: Rails.root.join("spec/support/fixtures")), - Chipmunk::Volume.new(name: "bags", package_type: Chipmunk::Bag, root_path: Chipmunk.config.upload.storage_path) - ]) - end -else - Services.register(:incoming_storage) do - Chipmunk::IncomingStorage.new( - volume: Chipmunk::Volume.new( - name: "incoming", - package_type: Chipmunk::Bag, - root_path: Chipmunk.config.upload.upload_path - ), - paths: Chipmunk::UserUploadPath.new("/"), - links: Chipmunk::UploadPath.new(Chipmunk.config.upload["rsync_point"]) +Services = Canister.new +Services.register(:incoming_storage) do + Chipmunk::IncomingStorage.new( + volume: Chipmunk::Volume.new( + name: "incoming", + reader: Chipmunk::Bag::Reader.new, + writer: Chipmunk::Bag::MoveWriter.new, + root_path: Chipmunk.config.upload.upload_path + ), + paths: Chipmunk::UserUploadPath.new("/"), + links: Chipmunk::UploadPath.new(Chipmunk.config.upload["rsync_point"]) + ) +end + +Services.register(:storage) do + Chipmunk::PackageStorage.new(volumes: [ + Chipmunk::Volume.new( + name: "root", + reader: Chipmunk::Bag::Reader.new, + writer: Chipmunk::Bag::MoveWriter.new, + root_path: "/" # For migration purposes + ), + Chipmunk::Volume.new( + name: "bags", + reader: Chipmunk::Bag::Reader.new, + writer: Chipmunk::Bag::MoveWriter.new, + root_path: Chipmunk.config.upload.storage_path ) - end - Services.register(:storage) do - Chipmunk::PackageStorage.new(volumes: [ - Chipmunk::Volume.new(name: "root", package_type: Chipmunk::Bag, root_path: "/"), # For migration purposes - Chipmunk::Volume.new(name: "bags", package_type: Chipmunk::Bag, root_path: Chipmunk.config.upload.storage_path) - ]) - end + ]) end +Services.register(:validation) { Chipmunk::ValidationService.new } + Services.register(:checkpoint) do Checkpoint::Authority.new(agent_resolver: KCV::AgentResolver.new, credential_resolver: Chipmunk::RoleResolver.new, @@ -79,3 +72,6 @@ def assign_db(lhs, rhs) end Services.register(:notary) { Keycard::Notary.default } +Services.register(:uuid_format) do + /\A[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\z/i +end diff --git a/config/routes.rb b/config/routes.rb index 566a455e..6b1a9601 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,13 @@ resources :audits, only: [:index, :create, :show] end + namespace :v2 do + resources :artifacts, only: [:index, :show, :create] + resources :deposits, only: [:index, :show, :create] + post "/artifacts/:artifact_id/revisions", controller: :deposits, action: :create + post "/deposits/:id/complete", controller: :deposits, action: :ready + end + get "/login", to: "login#new", as: "login" post "/login", to: "login#create", as: "login_as" match "/logout", to: "login#destroy", as: "logout", via: [:get, :post] diff --git a/db/migrate/20190802204940_create_artifacts.rb b/db/migrate/20190802204940_create_artifacts.rb new file mode 100644 index 00000000..52501572 --- /dev/null +++ b/db/migrate/20190802204940_create_artifacts.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateArtifacts < ActiveRecord::Migration[5.1] + def change + create_table :artifacts, id: :uuid do |t| + t.string :content_type, null: false + t.string :storage_volume, null: true + t.string :storage_format, null: false + t.references :user, null: false, index: false + t.timestamps + end + + add_index :artifacts, :user_id, unique: false + add_foreign_key :artifacts, :users + end +end diff --git a/db/migrate/20190807173150_create_deposits.rb b/db/migrate/20190807173150_create_deposits.rb new file mode 100644 index 00000000..5286c770 --- /dev/null +++ b/db/migrate/20190807173150_create_deposits.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateDeposits < ActiveRecord::Migration[5.1] + def change + create_table :deposits do |t| + t.string :artifact_id, null: false, index: false + t.belongs_to :user, null: false, index: false + t.string :status, null: false + t.text :error, null: false, default: "" + t.timestamps + end + + add_index :deposits, :user_id, unique: false + add_foreign_key :deposits, :artifacts + add_foreign_key :deposits, :users + end +end diff --git a/db/migrate/20190813153819_create_revisions.rb b/db/migrate/20190813153819_create_revisions.rb new file mode 100644 index 00000000..8b2862bd --- /dev/null +++ b/db/migrate/20190813153819_create_revisions.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateRevisions < ActiveRecord::Migration[5.1] + def change + create_table :revisions do |t| + t.string :artifact_id, null: false, index: false + t.belongs_to :deposit, null: false, index: false + t.timestamps + end + + add_index :revisions, :artifact_id, unique: false + add_index :revisions, :deposit_id, unique: true + add_foreign_key :revisions, :artifacts + add_foreign_key :revisions, :deposits + end +end diff --git a/db/schema.rb b/db/schema.rb index ba5d20d1..534eff6e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20190531175502) do +ActiveRecord::Schema.define(version: 20190813153819) do + +# Could not dump table "artifacts" because of following StandardError +# Unknown type 'uuid' for column 'id' create_table "audits", force: :cascade do |t| t.integer "user_id" @@ -20,6 +23,16 @@ t.index ["user_id"], name: "index_audits_on_user_id" end + create_table "deposits", force: :cascade do |t| + t.string "artifact_id", null: false + t.integer "user_id", null: false + t.string "status", null: false + t.text "error", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_deposits_on_user_id" + end + create_table "events", force: :cascade do |t| t.integer "package_id" t.string "event_type" @@ -58,6 +71,15 @@ t.index ["package_id"], name: "index_queue_items_on_package_id" end + create_table "revisions", force: :cascade do |t| + t.string "artifact_id", null: false + t.integer "deposit_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["artifact_id"], name: "index_revisions_on_artifact_id" + t.index ["deposit_id"], name: "index_revisions_on_deposit_id", unique: true + end + create_table "users", force: :cascade do |t| t.string "username", null: false t.string "email", null: false diff --git a/features/step_definitions/sort_these_asap.rb b/features/step_definitions/sort_these_asap.rb index f2cc5828..cf46e387 100644 --- a/features/step_definitions/sort_these_asap.rb +++ b/features/step_definitions/sort_these_asap.rb @@ -1,23 +1,37 @@ # frozen_string_literal: true Given("I am a content steward") do - pending # Write code here that turns the phrase above into concrete actions + make_me_a("content_manager").on_all("audio") end Given("an audio object that is not in the repository") do - pending # Write code here that turns the phrase above into concrete actions + @bag = Chipmunk::Bag.new(fixture("14d25bcd-deaf-4c94-add7-c189fdca4692")) end When("I deposit the object as a bag") do - pending # Write code here that turns the phrase above into concrete actions + api_post( + "/v2/artifacts", + id: @bag.external_id, + content_type: @bag.content_type, + storage_format: "bag" + ) + + api_post("/v2/artifacts/#{@bag.external_id}/revisions") + deposit = JSON.parse(last_response.body) + FileUtils.cp_r @bag.bag_dir, deposit["upload_link"].split(":").last + api_post("/v2/deposits/#{deposit["id"]}/complete") # Should this be a put? end Then("it is preserved as an artifact") do - pending # Write code here that turns the phrase above into concrete actions + @artifact = Artifact.find(@bag.external_id) + expect(Services.storage.for(@artifact)).to be_valid end Then("the artifact has the identifier from within the bag") do - pending # Write code here that turns the phrase above into concrete actions + api_get( + "/v2/artifacts/#{@bag.external_id}/" + ) + expect(last_response.successful?).to be true end Given("a preserved audio artifact") do diff --git a/lib/chipmunk.rb b/lib/chipmunk.rb index 896cfff7..4460d6a2 100644 --- a/lib/chipmunk.rb +++ b/lib/chipmunk.rb @@ -6,12 +6,15 @@ module Chipmunk require "semantic_logger" require_relative "chipmunk/errors" -require_relative "chipmunk/validatable" require_relative "chipmunk/bag" +require_relative "chipmunk/deposit_status" require_relative "chipmunk/resolvers" require_relative "chipmunk/incoming_storage" require_relative "chipmunk/package_storage" require_relative "chipmunk/upload_path" require_relative "chipmunk/user_upload_path" require_relative "chipmunk/volume" +require_relative "chipmunk/validator" +require_relative "chipmunk/validation_service" +require_relative "chipmunk/finish_deposit" diff --git a/lib/chipmunk/bag.rb b/lib/chipmunk/bag.rb index c469190a..39e6e3e1 100644 --- a/lib/chipmunk/bag.rb +++ b/lib/chipmunk/bag.rb @@ -6,13 +6,21 @@ module Chipmunk class Bag include SemanticLogger::Loggable + class << self + def format + "bag" + end + alias_method :storage_format, :format + end + def initialize(path, info = {}, _create = false) @bag = BagIt::Bag.new(path, info, _create) end - def self.format - "bag" + def format + self.class.format end + alias_method :storage_format, :format def path bag_dir.to_s @@ -118,5 +126,6 @@ def respond_to_missing?(name, include_private = false) end require_relative "bag/profile" +require_relative "bag/reader" +require_relative "bag/move_writer" require_relative "bag/tag" -require_relative "bag/validator" diff --git a/lib/chipmunk/bag/move_writer.rb b/lib/chipmunk/bag/move_writer.rb new file mode 100644 index 00000000..6b04cf54 --- /dev/null +++ b/lib/chipmunk/bag/move_writer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chipmunk + class Bag + + # Writes a bag by moving the source via file rename. + class MoveWriter + def write(bag, path) + FileUtils.mkdir_p(path) + File.rename(bag.path, path) + end + end + + end +end diff --git a/lib/chipmunk/bag/reader.rb b/lib/chipmunk/bag/reader.rb new file mode 100644 index 00000000..459eed01 --- /dev/null +++ b/lib/chipmunk/bag/reader.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chipmunk + class Bag + class Reader + def at(path) + Chipmunk::Bag.new(path) + end + + def storage_format + Bag.storage_format + end + end + end +end diff --git a/lib/chipmunk/bag/validator.rb b/lib/chipmunk/bag/validator.rb deleted file mode 100644 index 2868386e..00000000 --- a/lib/chipmunk/bag/validator.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -class Chipmunk::Bag - class Validator - include Chipmunk::Validatable - - attr_reader :errors - - # TODO: Decide exactly what this should take... Package, Bag, or what? - # @param package [Package] - # @param bag [Chipmunk::Package] If nil, the bag does not yet exist. - def initialize(package, errors = [], bag = nil) - @package = package - @bag = bag - @errors = errors - end - - # TODO: Separate ingest and storage validation: see PFDR-184 and Package#valid_for_ingest? - # Remove this when it has a proper home. - # validates "bag must exist on disk at src_path", - # condition: -> { File.exist?(src_path) }, - # error: -> { "Bag does not exist at upload location #{src_path}" } - - validates "bag on disk must be valid", - condition: -> { bag.valid? }, - error: -> { "Error validating bag:\n" + indent_array(bag.errors.full_messages) } - - { "External-Identifier" => :external_id, - "Bag-ID" => :bag_id, - "Chipmunk-Content-Type" => :content_type }.each_pair do |file_key, db_key| - validates "#{file_key} in bag on disk matches bag in database", - precondition: -> { [bag.chipmunk_info[file_key], package.public_send(db_key)] }, - condition: ->(file_val, db_val) { file_val == db_val }, - error: lambda {|file_val, db_val| - "uploaded #{file_key} '#{file_val}'" \ - " does not match expected value '#{db_val}'" - } - end - - validates "Bag ID in bag on disk matches bag in database", - condition: -> { bag.chipmunk_info["Bag-ID"] == package.bag_id }, - error: lambda { - "uploaded Bag-ID '#{bag.chipmunk_info["Bag-ID"]}'" \ - " does not match intended ID '#{package.bag_id}'" - } - - metadata_tags = ["Metadata-URL", "Metadata-Type", "Metadata-Tagfile"] - - validates "chipmunk-info.txt has metadata tags", - error: -> { "Some (but not all) metadata tags #{metadata_tags} missing in chipmunk-info.txt" }, - condition: lambda { - metadata_tags.all? {|tag| bag.chipmunk_info.key?(tag) } || - metadata_tags.none? {|tag| bag.chipmunk_info.key?(tag) } - } - - validates "bag on disk has referenced metadata files", - only_if: -> { bag.chipmunk_info["Metadata-Tagfile"] }, - error: -> { "Missing referenced metadata #{bag.chipmunk_info["Metadata-Tagfile"]}" }, - condition: lambda { - bag.tag_files.map {|f| File.basename(f) } - .include?(bag.chipmunk_info["Metadata-Tagfile"]) - } - - validates "bag on disk passes external validation", - only_if: -> { package.external_validation_cmd }, - precondition: -> { Open3.capture3(package.external_validation_cmd) }, - condition: ->(_, _, status) { status.exitstatus.zero? }, - error: ->(_, stderr, _) { "Error validating content\n" + stderr } - - validates "bag on disk meets bagger profile", - only_if: -> { package.bagger_profile }, - condition: -> { Profile.new(package.bagger_profile).valid?(bag.bag_info, errors: errors) }, - error: -> { "Not valid according to bagger profile" } - - private - - def indent_array(array, width = 2) - array.map {|s| " " * width + s }.join("\n") - end - - attr_reader :package, :bag - end -end diff --git a/lib/chipmunk/deposit_status.rb b/lib/chipmunk/deposit_status.rb new file mode 100644 index 00000000..41e2fe32 --- /dev/null +++ b/lib/chipmunk/deposit_status.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Chipmunk + + class DepositStatus + + VALUES = [ + :started, + :canceled, + :ingesting, + :failed, + :completed + ].freeze + + # Define a constructor for each value, e.g. DepositStatus.started + class << self + VALUES.each do |value| + define_method(value) do + new(value) + end + end + end + + # Define a predicate method #value? for each value, e.g. #completed? + VALUES.each do |value| + define_method(:"#{value}?") do + self.value == value + end + end + + def initialize(value) + @value = value.to_sym + unless VALUES.include?(@value) + raise ArgumentError, "Invalid value #{value}, expected one of #{VALUES}" + end + end + + def eql?(other) + value == other.value + end + + def to_s + value.to_s + end + + def to_sym + value + end + + def alive? + started? || ingesting? + end + + def dead? + !alive? + end + + private + + attr_reader :value + + end + +end diff --git a/lib/chipmunk/finish_deposit.rb b/lib/chipmunk/finish_deposit.rb new file mode 100644 index 00000000..281e058b --- /dev/null +++ b/lib/chipmunk/finish_deposit.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Chipmunk + class FinishDeposit + def initialize(deposit) + @deposit = deposit + @errors = [] + end + + def run + ingest! || record_failure + end + + private + + attr_reader :deposit, :errors + + def ingest! + validate && move_sip && record_success + rescue StandardError => e + log_exception(e) + end + + def validate + result = Services.validation.validate(deposit) + errors.concat result.errors + result.valid? + end + + def move_sip + sip = Services.incoming_storage.for(deposit) + Services.storage.write(deposit.artifact, sip) do |volume| + deposit.artifact.storage_volume = volume.name + end + end + + def record_success + deposit.transaction do + Revision.create!(deposit: deposit, artifact: deposit.artifact) + deposit.complete! + deposit.artifact.save! + end + end + + def record_failure + deposit.fail!(errors) + end + + def log_exception(exception) + errors << "#{exception.backtrace.first}: #{exception.message} (#{exception.class})" + errors << exception.backtrace.drop(1).map {|s| "\t#{s}" } + record_failure + end + + end +end diff --git a/lib/chipmunk/incoming_storage.rb b/lib/chipmunk/incoming_storage.rb index f01402ca..1d60e254 100644 --- a/lib/chipmunk/incoming_storage.rb +++ b/lib/chipmunk/incoming_storage.rb @@ -3,41 +3,41 @@ require "pathname" module Chipmunk - # IncomingStorage is responsible for the business rules of writing to and reading from - # Volumes earmarked for its use. It is specifically concerned with initial upload of bags, - # and makes them available to other parts of the system via its methods. + # IncomingStorage is responsible for the business rules of writing to and reading + # from Volumes earmarked for its use. It is specifically concerned with initial + # upload of bags, and makes them available to other parts of the system via its + # methods. class IncomingStorage # Create an IncomingStorage instance. - # @param volume [Volume] The Volume from which the deposited packages should be ingested - # @param paths [PathBuilder] A PathBuilder that returns a path on disk to the user upload - # location for a deposit, for a given package. - # @param links [PathBuilder] A PathBuilder that returns an rsync destination to which the - # user should upload, for a given package. + # @param volume [Volume] The Volume from which the deposited depositable items + # should be ingested + # @param paths [PathBuilder] A PathBuilder that returns a path on disk to the user + # upload location for a deposit, for a given depositable item. + # @param links [PathBuilder] A PathBuilder that returns an rsync destination to + # which the user should upload, for a given depositable item. def initialize(volume:, paths:, links:) @volume = volume @paths = paths @links = links end - def for(package) - raise Chipmunk::PackageAlreadyStoredError, package if package.stored? - - volume.get(upload_path(package)) + def for(depositable) + volume.get(upload_path(depositable)) end - def include?(package) - volume.include?(upload_path(package)) + def include?(depositable) + volume.include?(upload_path(depositable)) end - def upload_link(package) - links.for(package) + def upload_link(depositable) + links.for(depositable) end private - def upload_path(package) - paths.for(package) + def upload_path(depositable) + paths.for(depositable) end attr_reader :volume, :paths, :links diff --git a/lib/chipmunk/package_storage.rb b/lib/chipmunk/package_storage.rb index 48c9355d..0267c6f5 100644 --- a/lib/chipmunk/package_storage.rb +++ b/lib/chipmunk/package_storage.rb @@ -20,40 +20,41 @@ def initialize(volumes:) def for(package) raise Chipmunk::PackageNotStoredError, package unless package.stored? - volume_for(package).get(package.storage_path) + # This is backwards compatibility for Package#storage_path. Otherwise, we + # construct the storage path from the package's identifier. Package#storage_path + # is safe to remove now, but is nontrivial to do so. + storage_path = if package.respond_to?(:storage_path) + package.storage_path + else + storage_path_for(package) + end + volume_for(package).get(storage_path) end # Move the source archive into preservation storage and update the package's # storage_volume and storage_path accordingly. def write(package, source) - if package.format == Chipmunk::Bag.format - move_bag(package, source) - else - raise Chipmunk::UnsupportedFormatError, "Package #{package.bag_id} has invalid format: #{package.format}" - end + volume = destination_volume(package) + storage_path = storage_path_for(package) + volume.write(source, storage_path) + yield volume, storage_path end private - def move_bag(package, source) - bag_id = package.bag_id - prefixes = bag_id.match(/^(..)(..)(..).*/) - raise "bag_id too short: #{bag_id}" unless prefixes + def storage_path_for(package) + prefixes = package.identifier.match(/^(..)(..)(..).*/) + raise "identifier too short: #{package.identifier}" unless prefixes - storage_path = File.join("/", prefixes[1..3], bag_id) - volume = destination_volume(package) - dest_path = volume.expand(storage_path) - - FileUtils.mkdir_p(dest_path) - File.rename(source.path, dest_path) - - package.update(storage_volume: volume.name, storage_path: storage_path) + File.join("/", prefixes[1..3], package.identifier) end # We are defaulting everything to "bags" for now as the simplest resolution strategy. - def destination_volume(_package) + def destination_volume(package) volumes["bags"].tap do |volume| raise Chipmunk::VolumeNotFoundError, "Cannot find destination volume: bags" if volume.nil? + + unsupported_format!(volume, package) if volume.storage_format != package.storage_format end end @@ -61,12 +62,12 @@ def volume_for(package) volumes[package.storage_volume].tap do |volume| raise Chipmunk::VolumeNotFoundError, package.storage_volume if volume.nil? - unsupported_format!(volume, package) if volume.format != package.format + unsupported_format!(volume, package) if volume.storage_format != package.storage_format end end def unsupported_format!(volume, package) - raise Chipmunk::UnsupportedFormatError, "Volume #{volume.name} does not support #{package.format}" + raise Chipmunk::UnsupportedFormatError, "Volume #{volume.name} does not support #{package.storage_format}" end attr_reader :volumes diff --git a/lib/chipmunk/upload_path.rb b/lib/chipmunk/upload_path.rb index b4641c86..71da7a08 100644 --- a/lib/chipmunk/upload_path.rb +++ b/lib/chipmunk/upload_path.rb @@ -12,7 +12,7 @@ def initialize(root_path) end def for(package) - File.join(root_path, package.bag_id) + File.join(root_path, package.identifier) end private diff --git a/lib/chipmunk/user_upload_path.rb b/lib/chipmunk/user_upload_path.rb index 1b15c462..13fcb5b1 100644 --- a/lib/chipmunk/user_upload_path.rb +++ b/lib/chipmunk/user_upload_path.rb @@ -12,7 +12,7 @@ def initialize(root_path) end def for(package) - File.join(root_path, package.user.username, package.bag_id) + File.join(root_path, package.username, package.identifier) end private diff --git a/lib/chipmunk/validatable.rb b/lib/chipmunk/validatable.rb deleted file mode 100644 index d71bc2f4..00000000 --- a/lib/chipmunk/validatable.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Chipmunk - module Validatable - - def self.included(base) - base.extend(ClassMethods) - end - - module ClassMethods - def validators - @validators ||= [] - end - - def validates(_description = "", only_if: -> { true }, precondition: -> {}, condition:, error:) - validators << lambda do - return true unless instance_exec(&only_if) - - precond_result = instance_exec(&precondition) - if instance_exec(*precond_result, &condition) - true - else - errors << instance_exec(*precond_result, &error) - false - end - end - end - - end - - def valid? - self.class.validators.reduce(true) do |result, validator| - result && instance_exec(&validator) - end - end - - end -end diff --git a/lib/chipmunk/validation_result.rb b/lib/chipmunk/validation_result.rb new file mode 100644 index 00000000..294e43dc --- /dev/null +++ b/lib/chipmunk/validation_result.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Chipmunk + + # The result of a validation attempt + class ValidationResult + + def initialize(errors) + @errors = [errors].flatten.compact + @errors.freeze + end + + # @return [Array] + attr_reader :errors + + # @return [Boolean] + def valid? + errors.empty? + end + end + +end diff --git a/lib/chipmunk/validation_service.rb b/lib/chipmunk/validation_service.rb new file mode 100644 index 00000000..e33cdcbf --- /dev/null +++ b/lib/chipmunk/validation_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative "validation_result" + +module Chipmunk + + class ValidationService + + # @return [ValidationResult] + def validate(validatable) + case validatable + when Package + validate_with(validatable, package_validators(validatable)) + when Deposit + validate_with(validatable, deposit_validators(validatable)) + else + ValidationResult.new(["Did not recognize type #{validatable.class}"]) + end + end + + private + + def validate_with(validatable, validators) + if incoming_storage.include?(validatable) + sip = incoming_storage.for(validatable) + ValidationResult.new(errors(validators, sip)) + else + ValidationResult.new(["Could not find an uploaded sip"]) + end + end + + # TODO: decide which validators we need + def deposit_validators(deposit) + [ + Validator::BagConsistency.new, + Validator::External.new(deposit), + Validator::BaggerProfile.new(deposit) + ] + end + + def package_validators(package) + [ + Validator::StorageFormat.new(package.storage_format), + Validator::BagConsistency.new, + Validator::BagMatchesPackage.new(package), + Validator::External.new(package), + Validator::BaggerProfile.new(package) + ] + end + + # @param validators [Array] + # @param validatable [Object] The object being validated, e.g. a Bag + def errors(validators, validatable) + validators.reduce([]) do |errors, validator| + errors + validator.errors(validatable) + end + end + + def incoming_storage + Services.incoming_storage + end + end + +end diff --git a/lib/chipmunk/validator.rb b/lib/chipmunk/validator.rb new file mode 100644 index 00000000..dcfe8711 --- /dev/null +++ b/lib/chipmunk/validator.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Dir[File.join(__dir__, "validator", "**", "*.rb")].each {|file| require_relative file } diff --git a/lib/chipmunk/validator/bag_consistency.rb b/lib/chipmunk/validator/bag_consistency.rb new file mode 100644 index 00000000..abbfda56 --- /dev/null +++ b/lib/chipmunk/validator/bag_consistency.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "validator" + +module Chipmunk + module Validator + + # Validates the internal consistency of a bag, with no external information. + class BagConsistency < Validator + + METADATA_TAGS = ["Metadata-URL", "Metadata-Type", "Metadata-Tagfile"].freeze + + validates "bag on disk must be valid", + condition: proc {|bag| bag.valid? }, + error: proc {|bag| "Error validating bag:\n" + indent_array(bag.errors.full_messages) } + + validates "chipmunk-info.txt has metadata tags", + error: proc { "Some (but not all) metadata tags #{METADATA_TAGS} missing in chipmunk-info.txt" }, + condition: proc {|bag| + METADATA_TAGS.all? {|tag| bag.chipmunk_info.key?(tag) } || + METADATA_TAGS.none? {|tag| bag.chipmunk_info.key?(tag) } + } + + validates "bag on disk has referenced metadata files", + only_if: proc {|bag| bag.chipmunk_info["Metadata-Tagfile"] }, + error: proc {|bag| "Missing referenced metadata #{bag.chipmunk_info["Metadata-Tagfile"]}" }, + condition: proc {|bag| + bag.tag_files.map {|f| File.basename(f) } + .include?(bag.chipmunk_info["Metadata-Tagfile"]) + } + + private + + def indent_array(array, width = 2) + array.map {|s| " " * width + s }.join("\n") + end + end + + end +end diff --git a/lib/chipmunk/validator/bag_matches_package.rb b/lib/chipmunk/validator/bag_matches_package.rb new file mode 100644 index 00000000..d68b8dc8 --- /dev/null +++ b/lib/chipmunk/validator/bag_matches_package.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "validator" + +module Chipmunk + module Validator + + # Validate that the bag is indeed the one specified by the Package + class BagMatchesPackage < Validator + + # @param package [Package] + def initialize(package) + @package = package + end + + { + "External-Identifier" => :external_id, + "Bag-ID" => :bag_id, + "Chipmunk-Content-Type" => :content_type + }.each_pair do |file_key, db_key| + validates "#{file_key} in bag on disk matches bag in database", + condition: proc {|bag| bag.chipmunk_info[file_key] == package.public_send(db_key) }, + error: proc { "uploaded #{file_key} does not match expected value #{package.public_send(db_key)}" } + end + + private + + attr_reader :package + + end + end +end diff --git a/lib/chipmunk/validator/bagger_profile.rb b/lib/chipmunk/validator/bagger_profile.rb new file mode 100644 index 00000000..f73fc8f2 --- /dev/null +++ b/lib/chipmunk/validator/bagger_profile.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "validator" + +module Chipmunk + module Validator + + # Validate that the bag obeys the spec set by the profile + class BaggerProfile < Validator + + # @param [Package] + def initialize(package) + @package = package + end + + validates "bag on disk meets bagger profile", + only_if: proc { uri }, + precondition: proc {|bag| + [].tap {|errors| profile.valid?(bag.bag_info, errors: errors) } + }, + condition: proc {|_bag, errors| errors.empty? }, + error: proc {|_bag, errors| errors } + + private + + attr_reader :package + + def uri + @uri ||= Rails.application.config + .validation["bagger_profile"][package.content_type.to_s] + end + + def profile + if uri + Bag::Profile.new(uri) + end + end + end + + end +end diff --git a/lib/chipmunk/validator/external.rb b/lib/chipmunk/validator/external.rb new file mode 100644 index 00000000..9f84959b --- /dev/null +++ b/lib/chipmunk/validator/external.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "validator" +require "open3" + +module Chipmunk + module Validator + + # Validate that some external validation passes + class External < Validator + + # @param command [String] The fully specified command, including any reference + # to the object to be validated. + def initialize(package) + @package = package + end + + def command + if ext_bin + path = Services.incoming_storage.for(package).path + [ext_bin, package.identifier, path].join(" ") + end + end + + validates "the object passes external validation", + only_if: proc {|validatable| !command.nil? && validatable.valid? }, + precondition: proc { Open3.capture3(command) }, + condition: proc {|_, _, _, status| status.exitstatus.zero? }, + error: proc {|_, _, stderr, _| "Error validating content\n" + stderr } + + private + + attr_reader :package + + def ext_bin + @ext_bin ||= Rails.application.config + .validation["external"][package.content_type.to_s] + end + + end + + end +end diff --git a/lib/chipmunk/validator/storage_format.rb b/lib/chipmunk/validator/storage_format.rb new file mode 100644 index 00000000..df2db129 --- /dev/null +++ b/lib/chipmunk/validator/storage_format.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative "validator" + +module Chipmunk + module Validator + class StorageFormat < Validator + def initialize(storage_format) + @storage_format = storage_format + end + + validates "the storage_format matches", + condition: proc {|sip| sip.storage_format == storage_format }, + error: proc {|sip| + "SIP #{sip.identifier} has invalid storage_format: #{sip.storage_format}" + } + + private + + attr_reader :storage_format + end + end +end diff --git a/lib/chipmunk/validator/validator.rb b/lib/chipmunk/validator/validator.rb new file mode 100644 index 00000000..1745a1e9 --- /dev/null +++ b/lib/chipmunk/validator/validator.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Chipmunk + module Validator + + # A validator runs compile-time validations against a validatable object + # passed to #valid? or #errors during run-time. Validations are created + # with the DSL defined in this class via the ::validates method. Subclasses + # should define their own initializers and instance variables to facilitate + # run-time configuration of the validations. The validations themselves + # are run in instance context via instance_exec. + # + # This replaces Chipmunk::Validatable + class Validator + class << self + def validations + @validations ||= [] + end + + # Define a validation that instances of this validator will run + # @param _desc [String] A description of the check being performed. This + # string is discarded; it is for documentation purposes only. + # @param only_if [Proc] The condition will only be checked if + # this block evaluates to true, which it does by default. The object + # being validated is passed to this block. + # @param precondition [Proc] This will be evaluated prior to the condition + # and the error, and its result will be passed in expanded form to + # those procs. + # @param condition [Proc] The primary condition of this validation. + # The object being validated is passed to this block. + # @param error [Proc] A block to build out the error message; it must + # return a string. The object being validated is passed to this block. + def validates(_desc = "", only_if: proc { true }, precondition: proc {}, condition:, error:) + validations << lambda do |validatable| + return unless instance_exec(validatable, &only_if) + + precond_result = instance_exec(validatable, &precondition) + unless instance_exec(validatable, *precond_result, &condition) + instance_exec(validatable, *precond_result, &error) + end + end + end + end + + # Whether or not the validatable is valid. + # @param validatable [Object] An object that can be validated by this validator. + # @return [Boolean] + def valid?(validatable) + errors(validatable).empty? + end + + # An array of errors from the validation process. An empty array indicates + # a valid object. + # @param validatable [Object] An object that can be validated by this validator. + # @return [Array] An unordered list of errors. + def errors(validatable) + validated[validatable] ||= generate_errors(validatable) + end + + private + + # Actually run the validations, returning an array of errors. nil return + # values from the validations are removed. + def generate_errors(validatable) + self.class.validations.reduce([]) do |errs, validation| + errs << instance_exec(validatable, &validation) + end.compact + end + + # A cache of objects that have already been validated by this validator + # and their errors. + def validated + @validated = {} + end + + end + end + +end diff --git a/lib/chipmunk/volume.rb b/lib/chipmunk/volume.rb index 33831129..15aab345 100644 --- a/lib/chipmunk/volume.rb +++ b/lib/chipmunk/volume.rb @@ -15,14 +15,16 @@ class Volume # Create a new Volume. # # @param name [String] the name of the Volume; coerced to String - # @param package_type [Class] the storage class for this volume (Chipmunk::Bag) + # @param reader [Bag::Reader] A reader that can return the an instance of the + # stored object given a path. # @param root_path [String|Pathname] the path to the storage root for this Volume; # must be absolute; coerced to Pathname # @raise [ArgumentError] if the name is blank or root_path is relative - def initialize(name:, package_type:, root_path:) + def initialize(name:, root_path:, reader: Chipmunk::Bag::Reader.new, writer:) @name = name.to_s - @package_type = package_type @root_path = Pathname(root_path) + @reader = reader + @writer = writer validate! end @@ -47,7 +49,11 @@ def expand(path) def get(path) raise Chipmunk::PackageNotFoundError unless include?(path) - package_type.new(expand(path)) + reader.at(expand(path)) + end + + def write(object, path) + writer.write(object, expand(path)) end def include?(path) @@ -56,16 +62,18 @@ def include?(path) # @!attribute [r] # @return [String] the format name of the items in this volume - def format - package_type.format + def storage_format + reader.storage_format end + alias_method :format, :storage_format private def validate! raise ArgumentError, "Volume name must not be blank" if name.strip.empty? - raise ArgumentError, "Volume format must not be blank" if format.to_s.strip.empty? raise ArgumentError, "Volume root_path must be absolute (#{root_path})" unless root_path.absolute? + raise ArgumentError, "Volume must specify a reader" unless reader + raise ArgumentError, "Volume must specify a writer" unless writer end # Remove any leading slashes so Pathname joins properly @@ -73,6 +81,6 @@ def trim(path) path.to_s.sub(/^\/*/, "") end - attr_reader :package_type + attr_reader :reader, :writer end end diff --git a/spec/chipmunk/bag/move_writer_spec.rb b/spec/chipmunk/bag/move_writer_spec.rb new file mode 100644 index 00000000..f55f06ce --- /dev/null +++ b/spec/chipmunk/bag/move_writer_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# These tests were ported over from PackageStorage's specs. +RSpec.describe Chipmunk::Bag::MoveWriter do + let(:bag) { double(:bag, path: "/some/bag/path") } + let(:path) { "/bag/goes/in/here" } + + subject(:writer) { described_class.new } + + before(:each) do + allow(FileUtils).to receive(:mkdir_p) + allow(File).to receive(:rename) + end + + it "ensures the desination directory exists" do + expect(FileUtils).to receive(:mkdir_p).with(path) + writer.write(bag, path) + end + + it "moves the source to the destination directory" do + expect(File).to receive(:rename).with(bag.path, path) + writer.write(bag, path) + end +end diff --git a/spec/chipmunk/bag/validator_spec.rb b/spec/chipmunk/bag/validator_spec.rb deleted file mode 100644 index e4e180d4..00000000 --- a/spec/chipmunk/bag/validator_spec.rb +++ /dev/null @@ -1,168 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" -require "open3" - -RSpec.describe Chipmunk::Bag::Validator do - def exitstatus(status) - double(:exitstatus, exitstatus: status) - end - - let(:queue_item) { Fabricate(:queue_item) } - let(:package) { queue_item.package } - let(:good_tag_files) { [fixture("marc.xml")] } - - let(:chipmunk_info_db) do - { - "External-Identifier" => package.external_id, - "Chipmunk-Content-Type" => package.content_type, - "Bag-ID" => package.bag_id - } - end - - let(:chipmunk_info_with_metadata) do - chipmunk_info_db.merge( - "Metadata-Type" => "MARC", - "Metadata-URL" => "http://what.ever", - "Metadata-Tagfile" => "marc.xml" - ) - end - - # default (good case) - let(:fakebag) { double("fake bag", valid?: true, path: "/incoming/bag") } - let(:ext_validation_result) { ["", "", exitstatus(0)] } - let(:bag_info) { { "Foo" => "bar", "Baz" => "quux" } } - let(:tag_files) { good_tag_files } - let(:chipmunk_info) { chipmunk_info_with_metadata } - - let(:errors) { [] } - - around(:each) do |example| - old_profile = Rails.application.config.validation["bagger_profile"] - profile_uri = "file://" + Rails.root.join("spec", "support", "fixtures", "test-profile.json").to_s - Rails.application.config.validation["bagger_profile"] = { "digital" => profile_uri, "audio" => profile_uri } - example.run - Rails.application.config.validation["bagger_profile"] = old_profile - end - - describe "#valid?" do - let(:validator) { described_class.new(package, errors, fakebag) } - - before(:each) do - allow(Services.incoming_storage).to receive(:for).and_return(fakebag) - allow(fakebag).to receive(:chipmunk_info).and_return(chipmunk_info) - allow(fakebag).to receive(:tag_files).and_return(tag_files) - allow(fakebag).to receive(:bag_info).and_return(bag_info) - allow(Open3).to receive(:capture3).and_return(ext_validation_result) - end - - shared_examples_for "an invalid item" do |error_pattern| - it "records the validation error" do - validator.valid? - expect(errors).to include a_string_matching error_pattern - end - - it "returns false" do - expect(validator.valid?).to be false - end - end - - context "when the bag is valid" do - context "and its metadata matches the queue item" do - it "returns true" do - expect(validator.valid?).to be true - end - end - - context "but its external ID does not match the queue item" do - let(:chipmunk_info) { chipmunk_info_with_metadata.merge("External-Identifier" => "something-different") } - - it_behaves_like "an invalid item", /External-Identifier/ - end - - context "but its bag ID does not match the queue item" do - let(:chipmunk_info) { chipmunk_info_with_metadata.merge("Bag-ID" => "something-different") } - - it_behaves_like "an invalid item", /Bag-ID/ - end - - context "but its package type does not match the queue item" do - let(:chipmunk_info) { chipmunk_info_with_metadata.merge("Chipmunk-Content-Type" => "something-different") } - - it_behaves_like "an invalid item", /Chipmunk-Content-Type/ - end - - context "but does not include the referenced metadata file" do - let(:tag_files) { [] } - - it_behaves_like "an invalid item", /Missing.*marc.xml/ - end - - context "but does not any include descriptive metadata tags" do - let(:chipmunk_info) { chipmunk_info_db } - let(:tag_files) { [] } - - it "returns true" do - expect(validator.valid?).to be true - end - end - - context "but has only some descriptive metadata tags" do - let(:chipmunk_info) do - chipmunk_info_db.merge( - "Metadata-URL" => "http://what.ever", - "Metadata-Tagfile" => "marc.xml" - ) - end - - it_behaves_like "an invalid item", /Metadata-Type/ - end - - context "but external validation fails" do - around(:each) do |example| - old_ext_validation = Rails.application.config.validation["external"] - Rails.application.config.validation["external"] = { package.content_type => "something" } - example.run - Rails.application.config.validation["external"] = old_ext_validation - end - - let(:chipmunk_info) { chipmunk_info_with_metadata } - let(:ext_validation_result) { ["external output", "external error", exitstatus(1)] } - - it_behaves_like "an invalid item", /external error/ - end - - context "but the package type has no external validation command" do - around(:each) do |example| - old_ext_validation = Rails.application.config.validation["external"] - Rails.application.config.validation["external"] = {} - example.run - Rails.application.config.validation["external"] = old_ext_validation - end - - it "does not try to run external validation" do - expect(Open3).not_to receive(:capture3) - validator.valid? - end - end - end - - context "when the bag is invalid" do - let(:bag_errors) { double("bag_errors", full_messages: ["injected error"]) } - let(:fakebag) { double("fake bag", valid?: false, errors: bag_errors) } - - it_behaves_like "an invalid item", /Error validating.*\n injected error$/ - - it "does not try to run external validation" do - expect(Open3).not_to receive(:capture3) - validator.valid? - end - end - - context "with a bagger profile and bag not valid according to the profile" do - let(:bag_info) { { "Baz" => "quux" } } - - it_behaves_like "an invalid item", /Foo.*required/ - end - end -end diff --git a/spec/chipmunk/bag_spec.rb b/spec/chipmunk/bag_spec.rb index 8e8b9695..53cf7684 100644 --- a/spec/chipmunk/bag_spec.rb +++ b/spec/chipmunk/bag_spec.rb @@ -35,6 +35,13 @@ end end + describe "#format" do + it "has the same format as the class" do + expect(stored_bag.format).to eql(described_class.format) + expect(stored_bag.format).to eql("bag") + end + end + describe "#id" do it "returns the Bag-ID from the chipmunk-info.txt" do expect(stored_bag.id).to eql("14d25bcd-deaf-4c94-add7-c189fdca4692") diff --git a/spec/chipmunk/incoming_storage_spec.rb b/spec/chipmunk/incoming_storage_spec.rb index 78e23996..44dbc72b 100644 --- a/spec/chipmunk/incoming_storage_spec.rb +++ b/spec/chipmunk/incoming_storage_spec.rb @@ -1,12 +1,20 @@ # frozen_string_literal: true RSpec.describe Chipmunk::IncomingStorage do - let(:package_type) { double("SomePackageFormat", format: "some-pkg") } - let(:volume) { Chipmunk::Volume.new(name: "incoming", package_type: package_type, root_path: "/incoming") } + let(:reader) { double(:reader, format: "some-pkg") } + let(:writer) { double(:writer, format: "some-pkg") } + let(:volume) { Chipmunk::Volume.new(name: "incoming", writer: writer, reader: reader, root_path: "/incoming") } let(:path_builder) { Chipmunk::UploadPath.new("/") } - let(:uploader) { instance_double("User", username: "uploader") } - let(:unstored_package) { instance_double("Package", stored?: false, user: uploader, bag_id: "abcdef-123456") } - let(:stored_package) { instance_double("Package", stored?: true) } + let(:unstored_package) do + instance_double( + "Package", + stored?: false, + username: "uploader", + bag_id: "abcdef-123456", + identifier: "abcdef-123456" +) + end + let(:stored_package) { instance_double("Package", stored?: true) } subject(:storage) do described_class.new( @@ -22,12 +30,6 @@ end end - context "with a package that is already in preservation" do - it "raises an already-stored error" do - expect { storage.for(stored_package) }.to raise_error(Chipmunk::PackageAlreadyStoredError) - end - end - context "with a package that is uploaded to a user's directory (not yet in preservation)" do let(:incoming_bag) { double(:bag) } diff --git a/spec/chipmunk/package_storage_spec.rb b/spec/chipmunk/package_storage_spec.rb index ee46fd07..1bd8d219 100644 --- a/spec/chipmunk/package_storage_spec.rb +++ b/spec/chipmunk/package_storage_spec.rb @@ -2,17 +2,44 @@ RSpec.describe Chipmunk::PackageStorage do FakeBag = Struct.new(:storage_path) do - def self.format + def self.storage_format "bag" end end FakeZip = Struct.new(:storage_path) do - def self.format + def self.storage_format "zip" end end + class FakeBagReader + def storage_format + "bag" + end + + def at(path) + FakeBag.new(path) + end + end + + class FakeZipReader + def storage_format + "zip" + end + + def at(path) + FakeZip.new(path) + end + end + + class FakeBagWriter + def write(_obj, _path) + nil + end + end + FakeZipWriter = FakeBagWriter + # TODO: Set up a "test context" that has realistic, but test-focused # services registered. This will offload setup of the environment from # various tests. This may be as simple as registering test components over @@ -22,22 +49,37 @@ def self.format # inclusion of specific contexts in tests that need them. In this group, # the volumes and volume manager can be considered environmental, while the # packages are scenario data. - let(:bags) { Chipmunk::Volume.new(name: "bags", package_type: FakeBag, root_path: "/bags") } - let(:zips) { Chipmunk::Volume.new(name: "zips", package_type: FakeZip, root_path: "/zips") } + let(:bags) do + Chipmunk::Volume.new( + name: "bags", + root_path: "/bags", + reader: FakeBagReader.new, + writer: FakeBagWriter.new + ) + end + + let(:zips) do + Chipmunk::Volume.new( + name: "zips", + root_path: "/zips", + reader: FakeZipReader.new, + writer: FakeZipWriter.new + ) + end before(:each) do allow(bags).to receive(:include?).with("/a-bag").and_return true allow(zips).to receive(:include?).with("/a-zip").and_return true end - context "with two formats registered: bag and zip" do + context "with two storage_formats registered: bag and zip" do let(:storage) { described_class.new(volumes: [bags, zips]) } - let(:formats) { { bag: FakeBag, zip: FakeZip } } - let(:bag) { stored_package(format: "bag", storage_volume: "bags", storage_path: "/a-bag") } - let(:zip) { stored_package(format: "zip", storage_volume: "zips", storage_path: "/a-zip") } - let(:transient) { unstored_package(format: "bag", id: "abcdef-123456") } - let(:badvolume) { stored_package(format: "bag", storage_volume: "notfound") } + let(:storage_formats) { { bag: FakeBag, zip: FakeZip } } + let(:bag) { stored_package(storage_format: "bag", storage_volume: "bags", storage_path: "/a-bag") } + let(:zip) { stored_package(storage_format: "zip", storage_volume: "zips", storage_path: "/a-zip") } + let(:transient) { unstored_package(storage_format: "bag", id: "abcdef-123456") } + let(:badvolume) { stored_package(storage_format: "bag", storage_volume: "notfound") } let(:bag_proxy) { storage.for(bag) } let(:zip_proxy) { storage.for(zip) } @@ -71,40 +113,39 @@ def self.format context "with a good bag" do subject(:storage) { described_class.new(volumes: [bags]) } - let(:package) { spy(:package, format: "bag", bag_id: "abcdef-123456") } + let(:package) { spy(:package, storage_format: "bag", identifier: "abcdef-123456") } let(:disk_bag) { double(:bag, path: "/uploaded/abcdef-123456") } - before(:each) do - allow(FileUtils).to receive(:mkdir_p).with("/bags/ab/cd/ef/abcdef-123456") - allow(File).to receive(:rename).with("/uploaded/abcdef-123456", "/bags/ab/cd/ef/abcdef-123456") - end - - it "ensures the destination directory exists" do - expect(FileUtils).to receive(:mkdir_p) - storage.write(package, disk_bag) - end - it "moves the source bag to the destination directory" do - expect(File).to receive(:rename) - storage.write(package, disk_bag) + # TODO: This test is probably less specific than originally intended; setting + # an expection also adds an implicit allow(...) for the expectation. Here, + # originally it just expected File.rename to be called, regardless of args. + # Indeed, there seems to be a bit of a mismatch with these arguments across + # the few files concerned with it; somtimes they're bags, sometimes they're + # paths. We should be careful to make that clear so no issues get past + # testing. For the time being, I have left the expectation with the original + # specificity. + expect(bags).to receive(:write) + storage.write(package, disk_bag) {} end - it "sets the storage_volume" do - expect(package).to receive(:update).with(a_hash_including(storage_volume: "bags")) - storage.write(package, disk_bag) + it "yields the storage_volume" do + storage.write(package, disk_bag) do |actual_storage_volume, _| + expect(actual_storage_volume).to eql(bags) + end end - it "sets the storage_path with three levels of hierarchy" do - expect(package).to receive(:update) - .with(a_hash_including(storage_path: "/ab/cd/ef/abcdef-123456")) - storage.write(package, disk_bag) + it "yields the storage_path" do + storage.write(package, disk_bag) do |_, actual_storage_path| + expect(actual_storage_path).to eql("/ab/cd/ef/abcdef-123456") + end end end context "with a badly identified bag (shorter than 6 chars)" do subject(:storage) { described_class.new(volumes: [bags]) } - let(:package) { double(:package, format: "bag", bag_id: "ab12") } + let(:package) { double(:package, storage_format: "bag", identifier: "ab12") } let(:disk_bag) { double(:bag, path: "/uploaded/ab12") } it "raises an exception" do @@ -112,10 +153,10 @@ def self.format end end - context "with an unsupported archive format" do + context "with an unsupported archive storage_format" do subject(:storage) { described_class.new(volumes: [bags]) } - let(:package) { double(:package, format: "junk", bag_id: "id") } + let(:package) { double(:package, storage_format: "junk", identifier: "id") } let(:archive) { double(:archive) } it "raises an Unsupported Format error" do @@ -124,11 +165,11 @@ def self.format end end - def stored_package(format:, storage_volume: "test", storage_path: "/path") - double(:package, stored?: true, format: format.to_s, storage_volume: storage_volume, storage_path: storage_path) + def stored_package(storage_format:, storage_volume: "test", storage_path: "/path") + double(:package, stored?: true, storage_format: storage_format.to_s, storage_volume: storage_volume, storage_path: storage_path) end - def unstored_package(format:, id:) - double(:package, stored?: false, storage_volume: nil, storage_path: nil, format: format, bag_id: id) + def unstored_package(storage_format:, id:) + double(:package, stored?: false, storage_volume: nil, storage_path: nil, storage_format: storage_format, identifier: id) end end diff --git a/spec/chipmunk/validation_result_spec.rb b/spec/chipmunk/validation_result_spec.rb new file mode 100644 index 00000000..c187ddcf --- /dev/null +++ b/spec/chipmunk/validation_result_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe Chipmunk::ValidationResult do + describe "#valid?" do + it "is valid when there are no errors" do + expect(described_class.new([]).valid?).to be true + end + it "is invalid when there are errors" do + expect(described_class.new([1]).valid?).to be false + end + end + + describe "#errors" do + it "returns the errors" do + expect(described_class.new(["error"]).errors).to eql(["error"]) + end + + it "handles un-nested errors" do + expect(described_class.new("error").errors).to eql(["error"]) + end + + it "handles overly nested errors" do + expect(described_class.new([["error"]]).errors).to eql(["error"]) + end + + it "removes nils" do + expect(described_class.new([nil, "error"]).errors).to eql(["error"]) + end + end +end diff --git a/spec/chipmunk/validator/bag_consistency_spec.rb b/spec/chipmunk/validator/bag_consistency_spec.rb new file mode 100644 index 00000000..08052a33 --- /dev/null +++ b/spec/chipmunk/validator/bag_consistency_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +RSpec.describe Chipmunk::Validator::BagConsistency do + let(:validator) { described_class.new } + let(:tag_files) { [fixture("marc.xml")] } + let(:validity) { true } + let(:bag_errors) { double(:empty_bag_errors) } + let(:bag) do + double( + :bag, + valid?: validity, + errors: bag_errors, + tag_files: tag_files, + chipmunk_info: chipmunk_info + ) + end + let(:chipmunk_info) do + { + "Metadata-Type" => "MARC", + "Metadata-URL" => "http://what.ever", + "Metadata-Tagfile" => "marc.xml" + } + end + + context "when bag#valid? is false" do + let(:validity) { false } + let(:bag_errors) { double("bag_errors", full_messages: ["injected error"]) } + + it "is invalid" do + expect(validator.valid?(bag)).to be false + end + + it "reports the errors from the bag" do + expect(validator.errors(bag)).to include( + a_string_matching(/Error validating.*\n injected error$/) + ) + end + end + + context "when the bag does not include the referenced metadata file" do + let(:tag_files) { [] } + + it "reports missing tag files" do + expect(validator.errors(bag)) + .to include(a_string_matching(/Missing.*marc.xml/)) + end + end + + context "when the bag does not any include descriptive metadata tags" do + let(:tag_files) { [] } + let(:chipmunk_info) { {} } + + it "is valid" do + expect(validator.valid?(bag)).to be true + end + end + + context "when the bag has only some descriptive metadata tags" do + let(:chipmunk_info) do + { + "Metadata-URL" => "http://what.ever", + "Metadata-Tagfile" => "marc.xml" + } + end + + it "reports missing metadata tag" do + expect(validator.errors(bag)) + .to include(a_string_matching(/Metadata-Type/)) + end + end +end diff --git a/spec/chipmunk/validator/bag_matches_package_spec.rb b/spec/chipmunk/validator/bag_matches_package_spec.rb new file mode 100644 index 00000000..0c548967 --- /dev/null +++ b/spec/chipmunk/validator/bag_matches_package_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "securerandom" + +RSpec.describe Chipmunk::Validator::BagMatchesPackage do + let(:validator) { described_class.new(package) } + let(:bag) { double(:bag, chipmunk_info: chipmunk_info) } + let(:package) do + double( + :package, + bag_id: SecureRandom.uuid, + external_id: SecureRandom.uuid, + content_type: "audio" + ) + end + let(:good_chipmunk_info) do + { + "External-Identifier" => package.external_id, + "Chipmunk-Content-Type" => package.content_type, + "Bag-ID" => package.bag_id + } + end + + context "when everything matches" do + let(:chipmunk_info) { good_chipmunk_info } + + it { expect(validator.valid?(bag)).to be true } + end + + context "when its external ID does not match" do + let(:chipmunk_info) { good_chipmunk_info.merge("External-Identifier" => "something-different") } + + it "reports the error" do + expect(validator.errors(bag)) + .to contain_exactly(a_string_matching(/External-Identifier/)) + end + end + + context "when its bag ID does not match the queue item" do + let(:chipmunk_info) { good_chipmunk_info.merge("Bag-ID" => "something-different") } + + it "reports the error" do + expect(validator.errors(bag)) + .to contain_exactly(a_string_matching(/Bag-ID/)) + end + end + + context "when its package type does not match the queue item" do + let(:chipmunk_info) { good_chipmunk_info.merge("Chipmunk-Content-Type" => "something-different") } + + it "reports the error" do + expect(validator.errors(bag)) + .to contain_exactly(a_string_matching(/Chipmunk-Content-Type/)) + end + end +end diff --git a/spec/chipmunk/validator/bagger_profile_spec.rb b/spec/chipmunk/validator/bagger_profile_spec.rb new file mode 100644 index 00000000..9d8546ee --- /dev/null +++ b/spec/chipmunk/validator/bagger_profile_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.describe Chipmunk::Validator::BaggerProfile do + let(:validator) { described_class.new(package) } + let(:bag) { double(:bag, bag_info: { "Baz" => "quux" }) } + let(:package) { double(:package, content_type: "audio") } + + around(:each) do |example| + old_profile = Rails.application.config.validation["bagger_profile"]["audio"] + test_profile = "file://" + fixture("test-profile.json") + Rails.application.config.validation["bagger_profile"]["audio"] = test_profile.to_s + example.run + Rails.application.config.validation["bagger_profile"]["audio"] = old_profile + end + + it "tests the bag against the profile" do + expect(validator.errors(bag)).to include(a_string_matching(/Foo.*required/)) + end +end diff --git a/spec/chipmunk/validator/external_spec.rb b/spec/chipmunk/validator/external_spec.rb new file mode 100644 index 00000000..74f1c96e --- /dev/null +++ b/spec/chipmunk/validator/external_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.describe Chipmunk::Validator::External do + let(:content_type) { "mycontenttype" } + let(:package) { double(:package, identifier: SecureRandom.uuid, content_type: content_type) } + let(:bag) { double(:bag, valid?: true, path: "/incoming/bag") } + let(:validator) { described_class.new(package) } + + before(:each) do + allow(Services.incoming_storage).to receive(:for).and_return(bag) + end + + context "when there is no external command configured" do + around(:each) do |example| + old_ext_validation = Rails.application.config.validation["external"] + Rails.application.config.validation["external"] = {} + example.run + Rails.application.config.validation["external"] = old_ext_validation + end + + it "is valid" do + expect(validator.valid?(bag)).to be true + end + end + + context "when the external command succeeds" do + around(:each) do |example| + old_ext_validation = Rails.application.config.validation["external"] + Rails.application.config.validation["external"] = { content_type => "/bin/true" } + example.run + Rails.application.config.validation["external"] = old_ext_validation + end + + it "constructs the command with the configured executable" do + expect(validator.command).to match(/^\/bin\/true/) + end + + it "includes the path to the source archive" do + expect(validator.command).to match("/incoming/bag") + end + + it "is valid" do + expect(validator.valid?(bag)).to be true + end + end + + context "when the external command fails" do + around(:each) do |example| + old_ext_validation = Rails.application.config.validation["external"] + Rails.application.config.validation["external"] = { content_type => "ls /nondir" } + example.run + Rails.application.config.validation["external"] = old_ext_validation + end + + it { expect(validator.valid?(bag)).to be false } + + it "reports the error" do + expect(validator.errors(bag)).to include(a_string_matching(/cannot access/)) + end + + it "skips this validator if the bag is invalid" do + bag = double(:invalid_bag, valid?: false) + expect(validator.valid?(bag)).to be true + end + end +end diff --git a/spec/chipmunk/validator/storage_format_spec.rb b/spec/chipmunk/validator/storage_format_spec.rb new file mode 100644 index 00000000..c9ff28e7 --- /dev/null +++ b/spec/chipmunk/validator/storage_format_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe Chipmunk::Validator::StorageFormat do + let(:storage_format) { "somestorage_format" } + let(:validator) { described_class.new(storage_format) } + + context "when the storage_format matches" do + let(:sip) { double(:sip, storage_format: storage_format) } + + it "is valid" do + expect(validator.valid?(sip)).to be true + end + end + + context "when the storage_format diverges" do + let(:sip) { double(:sip, identifier: "someid", storage_format: "sandwich") } + + it "reports the error" do + expect(validator.errors(sip)) + .to include("SIP someid has invalid storage_format: sandwich") + end + end +end diff --git a/spec/chipmunk/validator/validator_spec.rb b/spec/chipmunk/validator/validator_spec.rb new file mode 100644 index 00000000..7b5a7c5d --- /dev/null +++ b/spec/chipmunk/validator/validator_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +RSpec.describe Chipmunk::Validator::Validator do + class OnlyIfValidator < described_class + def initialize(only_if: true) + @only_if = only_if + end + + validates "For testing only_if", + only_if: proc { @only_if }, # rubocop:disable RSpec/InstanceVariable + precondition: proc { true }, + condition: proc { false }, + error: proc { "injected_error" } + end + + class PrecondValidator < described_class + def initialize(precond) + @precond = precond + end + + validates "For testing preconditions", + only_if: proc { true }, + precondition: proc { @precond }, # rubocop:disable RSpec/InstanceVariable + condition: proc {|_, precond_result| precond_result }, + error: proc {|_, precond_result| "#{precond_result} injected_error" } + end + + class BlockParamValidator < described_class + validates "For testing that blocks receive the object being validated", + only_if: proc {|v| v.called_by << :only_if; true }, + precondition: proc {|v| v.called_by << :precondition; true }, + condition: proc {|v| v.called_by << :condition; false }, + error: proc {|v| v.called_by << :error; "some_error" } + end + + let(:validatable) { double(:validatable) } + + context "when only_if is true" do + let(:failure) { OnlyIfValidator.new(only_if: true) } + + it "runs the validation" do + expect(failure.valid?(validatable)).to be false + end + end + + context "when only_if is false" do + let(:success) { OnlyIfValidator.new(only_if: false) } + + it "skips the validation" do + expect(success.valid?(validatable)).to be true + end + end + + context "with a precondition" do + let(:failure) { PrecondValidator.new(false) } + + it "passes the precondition to the condition" do + expect(failure.valid?(validatable)).to be false + end + + it "passes the precondition to the error" do + expect(failure.errors(validatable)).to include("false injected_error") + end + end + + describe "each block receives the object being validated" do + let(:validatable) { double(:validatable, called_by: []) } + let(:failure) { BlockParamValidator.new } + + it "passes the validatable to each block" do + failure.valid?(validatable) + expect(validatable.called_by) + .to contain_exactly(:only_if, :precondition, :condition, :error) + end + end +end diff --git a/spec/chipmunk/volume_spec.rb b/spec/chipmunk/volume_spec.rb index 0da68047..9adc67d3 100644 --- a/spec/chipmunk/volume_spec.rb +++ b/spec/chipmunk/volume_spec.rb @@ -3,12 +3,20 @@ require "pathname" RSpec.describe Chipmunk::Volume do - subject(:volume) { described_class.new(name: name, package_type: package_type, root_path: root_path) } + subject(:volume) do + described_class.new( + name: name, + root_path: root_path, + reader: reader, + writer: writer + ) + end context "when given valid attributes" do let(:name) { "vol" } let(:proxy) { double(:storage_proxy) } - let(:package_type) { double("SomeFormat", new: proxy, format: "some-pkg") } + let(:reader) { double(:reader, at: proxy, storage_format: "some-pkg") } + let(:writer) { double(:writer, write: nil) } let(:root_path) { "/path" } it "has the correct name" do @@ -19,6 +27,10 @@ expect(volume.format).to eq "some-pkg" end + it "has the correct storage_format" do + expect(volume.storage_format).to eq "some-pkg" + end + it "has the correct root path" do expect(volume.root_path).to eq Pathname("/path") end @@ -56,8 +68,9 @@ end context "when given a blank name" do - let(:name) { "" } - let(:package_type) { double("SomeFormat", new: nil, format: "some-pkg") } + let(:name) { "" } + let(:reader) { double(:reader, at: nil, format: "some-pkg") } + let(:writer) { double(:writer, write: nil, format: "some-pkg") } let(:root_path) { "/path" } it "raises an argument error that the name must not be blank" do @@ -66,8 +79,9 @@ end context "when given a relative path" do - let(:name) { "vol" } - let(:package_type) { double("SomeFormat", new: nil, format: "some-pkg") } + let(:name) { "vol" } + let(:reader) { double(:reader, at: nil, format: "some-pkg") } + let(:writer) { double(:writer, write: nil, format: "some-pkg") } let(:root_path) { "relative/path" } it "raises an argument error that path must be absolute" do diff --git a/spec/fabricators/artifact_fabricactor.rb b/spec/fabricators/artifact_fabricactor.rb new file mode 100644 index 00000000..fe2f7358 --- /dev/null +++ b/spec/fabricators/artifact_fabricactor.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Fabricator(:artifact) do + id { SecureRandom.uuid } + user { Fabricate(:user) } + storage_format { ["bag", "bag:versioned"].sample } + content_type { ["digital", "audio"].sample } + revisions [] +end diff --git a/spec/fabricators/deposit_fabricator.rb b/spec/fabricators/deposit_fabricator.rb new file mode 100644 index 00000000..9968168e --- /dev/null +++ b/spec/fabricators/deposit_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:deposit) do + user + artifact + status { Deposit.statuses[:started] } +end diff --git a/spec/fabricators/revision_fabricator.rb b/spec/fabricators/revision_fabricator.rb new file mode 100644 index 00000000..23d326ec --- /dev/null +++ b/spec/fabricators/revision_fabricator.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Fabricator(:revision) do + artifact + deposit +end diff --git a/spec/jobs/bag_move_job_spec.rb b/spec/jobs/bag_move_job_spec.rb index 3fbedc13..eeb312bd 100644 --- a/spec/jobs/bag_move_job_spec.rb +++ b/spec/jobs/bag_move_job_spec.rb @@ -6,42 +6,24 @@ let(:queue_item) { Fabricate(:queue_item) } let(:package) { queue_item.package } - let(:chipmunk_info_db) do - { - "External-Identifier" => package.external_id, - "Chipmunk-Content-Type" => package.content_type, - "Bag-ID" => package.bag_id - } - end - - let(:chipmunk_info_good) do - chipmunk_info_db.merge( - "Metadata-Type" => "MARC", - "Metadata-URL" => "http://what.ever", - "Metadata-Tagfile" => "marc.xml" - ) - end - describe "#perform" do let(:bag) { double(:bag, path: "/uploaded/bag") } + let(:volume) { double(:volume, name: "bags") } before(:each) do + allow(Services.validation).to receive(:validate).with(package).and_return(validation_result) allow(Services.incoming_storage).to receive(:for).with(package).and_return(bag) - allow(Services.storage).to receive(:write).with(package, bag) do |pkg, _bag| - pkg.storage_volume = "bags" - pkg.storage_path = "/storage/path/to/#{pkg.bag_id}" - end + allow(Services.storage).to receive(:write).with(package, bag) + .and_yield(volume, "/storage/path/to/#{package.bag_id}") end context "when the package is valid" do subject(:run_job) { described_class.perform_now(queue_item) } - before(:each) do - allow(queue_item.package).to receive(:valid_for_ingest?).and_return true - end + let(:validation_result) { double(:validation_result, errors: [], valid?: true) } it "moves the bag" do - expect(Services.storage).to receive(:write).with(queue_item.package, bag) + expect(Services.storage).to receive(:write) run_job end @@ -50,12 +32,17 @@ expect(queue_item.status).to eql("done") end - # TODO: Make sure that the destination volume is set properly, not literally; see PFDR-185 - it "sets the package storage_volume to root" do + it "sets the package storage_volume" do run_job expect(queue_item.package.storage_volume).to eql("bags") end + it "sets the package storage_path" do + run_job + expect(package.storage_path) + .to eql("/storage/path/to/#{package.bag_id}") + end + context "but the move fails" do before(:each) do allow(Services.storage).to receive(:write).with(package, bag).and_raise "test move failed" @@ -80,15 +67,10 @@ context "when the package is invalid" do subject(:run_job) { described_class.perform_now(queue_item) } - before(:each) do - allow(queue_item.package).to receive(:valid_for_ingest?) do |errors| - errors << "my error" - false - end - end + let(:validation_result) { double(valid?: false, errors: ["my error"]) } it "does not move the bag" do - expect(Services.storage).not_to receive(:write).with(package, anything) + expect(Services.storage).not_to receive(:write) run_job end @@ -101,33 +83,6 @@ run_job expect(queue_item.error).to match(/my error/) end - - it "does not move the bag" do - expect(Services.storage).not_to receive(:write).with(package, anything) - run_job - end - end - - context "when validation raises an exception" do - subject(:run_job) { described_class.perform_now(queue_item) } - - before(:each) do - allow(queue_item.package).to receive(:valid_for_ingest?).and_raise("test validation failure") - end - - it "re-raises the exception" do - expect { run_job }.to raise_exception(/test validation failure/) - end - - it "records the exception" do - run_job rescue StandardError - expect(queue_item.error).to match(/test validation failure/) - end - - it "records the stack trace" do - run_job rescue StandardError - expect(queue_item.error).to match(__FILE__) - end end end end diff --git a/spec/models/artifact_spec.rb b/spec/models/artifact_spec.rb new file mode 100644 index 00000000..e5ef3f1e --- /dev/null +++ b/spec/models/artifact_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Artifact, type: :model do + let(:id) { SecureRandom.uuid } + let(:user) { Fabricate.create(:user) } + let(:content_type) { ["digital", "audio"].sample } + let(:storage_format) { ["bag", "bag:versioned"].sample } + + it "has a valid fabricator" do + expect(Fabricate.create(:artifact)).to be_valid + end + + it "has a valid fabriactor that doesn't save to the database" do + expect(Fabricate.build(:artifact)).to be_valid + end + + describe "#id" do + it "must be unique" do + expect do + 2.times { Fabricate(:artifact, id: id) } + end.to raise_error ActiveRecord::RecordNotUnique + end + + it "must be a UUIDv4" do + expect(Fabricate.build(:artifact, id: "foo")).not_to be_valid + end + end + + describe "#stored?" do + it "is false if there are exactly zero revisions" do + expect(Fabricate.build(:artifact, revisions: []).stored?).to be false + end + it "is true if it has 1 or more revisions" do + artifact = Fabricate.build( + :artifact, + revisions: [Fabricate.build(:revision)] + ) + expect(artifact.stored?).to be true + end + end +end diff --git a/spec/models/deposit_spec.rb b/spec/models/deposit_spec.rb new file mode 100644 index 00000000..f937e64d --- /dev/null +++ b/spec/models/deposit_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Deposit, type: :model do + it_behaves_like "a depositable item" do + let(:instance) { Fabricate.build(:deposit) } + end + + it "has a valid fabricator" do + expect(Fabricate.create(:deposit)).to be_valid + end + + it "has a valid fabriactor that doesn't save to the database" do + expect(Fabricate.build(:deposit)).to be_valid + end + + describe "#storage_format" do + let(:deposit) { Fabricate.build(:deposit) } + + it "uses the artifact's storage_format" do + expect(deposit.storage_format).to eql(deposit.artifact.storage_format) + end + end + + describe "#fail!" do + let(:deposit) { Fabricate.create(:deposit) } + let(:error) { ["some", "errors"] } + + it "updates with status failed" do + expect { deposit.fail!(error) } + .to change(deposit, :status) + .to("failed") + end + + it "updates with the error" do + expect { deposit.fail!(error) } + .to change(deposit, :error) + .to("some\nerrors") + end + + it "saves the record" do + deposit.fail!(error) + expect(deposit.changed?).to be false + end + end +end diff --git a/spec/models/package_spec.rb b/spec/models/package_spec.rb index 398692fd..262e220b 100644 --- a/spec/models/package_spec.rb +++ b/spec/models/package_spec.rb @@ -8,6 +8,10 @@ let(:storage_path) { Rails.application.config.upload["storage_path"] } let(:uuid) { "6d11833a-d5fd-44f8-9205-277218578901" } + it_behaves_like "a depositable item" do + let(:instance) { Fabricate.build(:package) } + end + [:bag_id, :user_id, :external_id, :format, :content_type].each do |field| it "#{field} is required" do expect(Fabricate.build(:package, field => nil)).not_to be_valid @@ -36,126 +40,6 @@ expect(request.upload_link).to eq(File.join(upload_link, uuid)) end - # TODO: This group's ugliness is because we have odd and temporary coupling between - # Package and Bag::Validator. Once the ingest/storage phases are separated, this will - # clean up considerably. See PFDR-184. - describe "#valid_for_ingest?" do - context "with an unstored bag" do - let(:package) { Fabricate.build(:unstored_package) } - let(:validator) { double(:validator) } - let(:disk_bag) { double(:bag) } - - before(:each) do - allow(Services.incoming_storage).to receive(:include?).with(package).and_return(true) - allow(Services.incoming_storage).to receive(:for).with(package).and_return(disk_bag) - allow(Chipmunk::Bag::Validator).to receive(:new).with(package, anything, disk_bag).and_return(validator) - end - - it "validates the bag with its validator" do - expect(validator).to receive(:valid?) - package.valid_for_ingest? - end - end - - context "with a stored bag" do - let(:package) { Fabricate.build(:stored_package) } - let(:result) { package.valid_for_ingest? } - - it "fails" do - expect(result).to be false - end - - it "does not create or use the bag validator" do - expect(Chipmunk::Bag::Validator).not_to receive(:new) - result - end - end - - context "with a bag with a bad path" do - let(:package) { Fabricate.build(:stored_package) } - let(:result) { package.valid_for_ingest? } - - before(:each) do - allow(Services.incoming_storage).to receive(:include?).and_return(false) - end - - it "fails" do - expect(result).to be false - end - - it "does not create or use the bag validator" do - expect(Chipmunk::Bag::Validator).not_to receive(:new) - result - end - end - - context "with a plain zip" do - let(:package) { Fabricate.build(:stored_package) } - let(:result) { package.valid_for_ingest? } - - it "fails" do - expect(result).to be false - end - - it "does not create or use the bag validator" do - expect(Chipmunk::Bag::Validator).not_to receive(:new) - result - end - end - end - - describe "#external_validation_cmd" do - let(:package) { Fabricate.build(:package) } - let(:bag) { double(:bag, path: "/incoming/bag") } - - before(:each) do - allow(Services.incoming_storage).to receive(:for).and_return(bag) - end - - context "when there is an external command configured" do - around(:each) do |example| - old_ext_validation = Rails.application.config.validation["external"] - Rails.application.config.validation["external"] = { package.content_type => "/bin/true" } - example.run - Rails.application.config.validation["external"] = old_ext_validation - end - - it "returns a command starting with the configured executable" do - expect(package.external_validation_cmd).to match(/^\/bin\/true/) - end - - it "includes the path to the source archive" do - expect(package.external_validation_cmd).to match("/incoming/bag") - end - end - - context "when there is no external command configured" do - around(:each) do |example| - old_ext_validation = Rails.application.config.validation["external"] - Rails.application.config.validation["external"] = {} - example.run - Rails.application.config.validation["external"] = old_ext_validation - end - - it "returns nil" do - expect(package.external_validation_cmd).to be_nil - end - end - end - - describe "#bagger_profile" do - around(:each) do |example| - old_profile = Rails.application.config.validation["bagger_profile"]["audio"] - Rails.application.config.validation["bagger_profile"]["audio"] = "foo" - example.run - Rails.application.config.validation["bagger_profile"]["audio"] = old_profile - end - - it "returns a bagger profile" do - expect(Fabricate.build(:package).bagger_profile).not_to be_nil - end - end - describe "#to_param" do it "uses the bag id" do bag_id = "made_up" diff --git a/spec/models/revision_spec.rb b/spec/models/revision_spec.rb new file mode 100644 index 00000000..94fa9d3e --- /dev/null +++ b/spec/models/revision_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Revision, type: :model do + it "has a valid fabricator" do + expect(Fabricate.create(:revision)).to be_valid + end + + it "has a valid fabriactor that doesn't save to the database" do + expect(Fabricate.build(:revision)).to be_valid + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 05efcb36..f342955d 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,59 +1,44 @@ # frozen_string_literal: true -# This file is copied to spec/ when you run 'rails generate rspec:install' require "spec_helper" ENV["RAILS_ENV"] ||= "test" require File.expand_path("../config/environment", __dir__) -# Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? require "rspec/rails" -# Add additional requires below this line. Rails is not loaded until this point! -# Requires supporting ruby files with custom matchers and macros, etc, in -# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are -# run as spec files by default. This means that files in spec/support that end -# in _spec.rb will both be required and run as specs, causing the specs to be -# run twice. It is recommended that you do not name files matching this glob to -# end with _spec.rb. You can configure this pattern with the --pattern -# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. -# -# The following line is provided for convenience purposes. It has the downside -# of increasing the boot-up time by auto-requiring all files in the support -# directory. Alternatively, in the individual `*_spec.rb` files, manually -# require only the support files necessary. -# -# Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } - -# Checks for pending migration and applies them before tests are run. -# If you are not using ActiveRecord, you can remove this line. ActiveRecord::Migration.maintain_test_schema! RSpec.configure do |config| - # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = "#{::Rails.root}/spec/fixtures" - - # If you're not using ActiveRecord, or you'd prefer not to run each of your - # examples within a transaction, remove the following line or assign false - # instead of true. config.use_transactional_fixtures = true - - # RSpec Rails can automatically mix in different behaviours to your tests - # based on their file location, for example enabling you to call `get` and - # `post` in specs under `spec/controllers`. - # - # You can disable this behaviour by removing the line below, and instead - # explicitly tag your specs with their type, e.g.: - # - # RSpec.describe UsersController, :type => :controller do - # # ... - # end - # - # The different available types are documented in the features, such as in - # https://relishapp.com/rspec/rspec-rails/docs config.infer_spec_type_from_file_location! +end + +Services.register(:incoming_storage) do + Chipmunk::IncomingStorage.new( + volume: Chipmunk::Volume.new( + name: "incoming", + reader: Chipmunk::Bag::Reader.new, + writer: Chipmunk::Bag::MoveWriter.new, + root_path: Chipmunk.config.upload.upload_path + ), + paths: Chipmunk::UploadPath.new("/"), + links: Chipmunk::UploadPath.new(Chipmunk.config.upload["rsync_point"]) + ) +end - # Filter lines from Rails gems in backtraces. - config.filter_rails_from_backtrace! - # arbitrary gems may also be filtered via: - # config.filter_gems_from_backtrace("gem name") +Services.register(:storage) do + Chipmunk::PackageStorage.new(volumes: [ + Chipmunk::Volume.new( + name: "test", + reader: Chipmunk::Bag::Reader.new, + writer: Chipmunk::Bag::MoveWriter.new, + root_path: Rails.root.join("spec/support/fixtures") + ), + Chipmunk::Volume.new( + name: "bags", + reader: Chipmunk::Bag::Reader.new, + writer: Chipmunk::Bag::MoveWriter.new, + root_path: Chipmunk.config.upload.storage_path + ) + ]) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 97815134..d30f04f4 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,38 +6,16 @@ require "chipmunk" RSpec.configure do |config| - # rspec-expectations config goes here. You can use an alternate - # assertion/expectation library such as wrong or the stdlib/minitest - # assertions if you prefer. config.expect_with :rspec do |expectations| - # This option will default to `true` in RSpec 4. It makes the `description` - # and `failure_message` of custom matchers include text for helper methods - # defined using `chain`, e.g.: - # be_bigger_than(2).and_smaller_than(4).description - # # => "be bigger than 2 and smaller than 4" - # ...rather than: - # # => "be bigger than 2" expectations.include_chain_clauses_in_custom_matcher_descriptions = true end - # rspec-mocks config goes here. You can use an alternate test double - # library (such as bogus or mocha) by changing the `mock_with` option here. config.mock_with :rspec do |mocks| - # Prevents you from mocking or stubbing a method that does not exist on - # a real object. This is generally recommended, and will default to - # `true` in RSpec 4. mocks.verify_partial_doubles = true end - # This option will default to `:apply_to_host_groups` in RSpec 4 (and will - # have no way to turn it off -- the option exists only for backwards - # compatibility in RSpec 3). It causes shared context metadata to be - # inherited by the metadata hash of host groups and examples, rather than - # triggering implicit auto-inclusion in groups with matching metadata. config.shared_context_metadata_behavior = :apply_to_host_groups - config.filter_run_excluding integration: true unless ENV["RUN_INTEGRATION"] - config.example_status_persistence_file_path = "spec/examples.txt" config.after(:each) do # Reset the unique generator used for usernames for each example diff --git a/spec/support/examples/a_depositable_item.rb b/spec/support/examples/a_depositable_item.rb new file mode 100644 index 00000000..29201914 --- /dev/null +++ b/spec/support/examples/a_depositable_item.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a depositable item" do + let(:instance) { described_class.new } + + it "#identifier is a String" do + expect(instance.identifier).to be_a_kind_of(String) + end + + it "#username is a String" do + expect(instance.username).to be_a_kind_of(String) + end +end diff --git a/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/bag-info.txt b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/bag-info.txt new file mode 100644 index 00000000..74896c84 --- /dev/null +++ b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/bag-info.txt @@ -0,0 +1,3 @@ +Bag-Software-Agent: BagIt Ruby Gem (http://bagit.rubyforge.org) +Bagging-Date: 2017-06-19 +Payload-Oxum: 11.1 diff --git a/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/bagit.txt b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/bagit.txt new file mode 100644 index 00000000..c4aebb43 --- /dev/null +++ b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/bagit.txt @@ -0,0 +1,2 @@ +BagIt-Version: 0.97 +Tag-File-Character-Encoding: UTF-8 diff --git a/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/chipmunk-info.txt b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/chipmunk-info.txt new file mode 100644 index 00000000..152b8126 --- /dev/null +++ b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/chipmunk-info.txt @@ -0,0 +1,6 @@ +External-Identifier: 32334b0c-3977-4ae4-b5dd-57fedb60a789 +Chipmunk-Content-Type: audio +Bag-ID: 14d25bcd-deaf-4c94-add7-c189fdca4692 +Metadata-Tagfile: meta.xml +Metadata-Type: yes +Metadata-URL: http://what.ever diff --git a/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/data/samplefile b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/data/samplefile new file mode 100644 index 00000000..bb215b5b --- /dev/null +++ b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/data/samplefile @@ -0,0 +1 @@ +Hello Bag! diff --git a/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/manifest-md5.txt b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/manifest-md5.txt new file mode 100644 index 00000000..98a00b26 --- /dev/null +++ b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/manifest-md5.txt @@ -0,0 +1 @@ +71da23dbd1935f78b24c790c47992691 data/samplefile diff --git a/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/manifest-sha1.txt b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/manifest-sha1.txt new file mode 100644 index 00000000..6053ea85 --- /dev/null +++ b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/manifest-sha1.txt @@ -0,0 +1 @@ +6570a827884d8936ea2f8b084705c24aa6f9d7ee data/samplefile diff --git a/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/meta.xml b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/meta.xml new file mode 100644 index 00000000..e69de29b diff --git a/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/tagmanifest-md5.txt b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/tagmanifest-md5.txt new file mode 100644 index 00000000..27832f76 --- /dev/null +++ b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/tagmanifest-md5.txt @@ -0,0 +1,6 @@ +22340b9567d070bf6022b85e68948727 bag-info.txt +9e5ad981e0d29adc278f6a294b8c2aca bagit.txt +16f51a4d0d65ed218dbc53783320d92a chipmunk-info.txt +a7a2803e003c74159725db55d91a5981 manifest-md5.txt +c5f316f266735235e4c62c88983ac711 manifest-sha1.txt +d41d8cd98f00b204e9800998ecf8427e meta.xml diff --git a/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/tagmanifest-sha1.txt b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/tagmanifest-sha1.txt new file mode 100644 index 00000000..71705a82 --- /dev/null +++ b/spec/support/fixtures/14d25bcd-deaf-4c94-add7-c189fdca4692/tagmanifest-sha1.txt @@ -0,0 +1,6 @@ +e29dcf1ce489cb9fe75631a16918c1206330f01b bag-info.txt +e2924b081506bac23f5fffe650ad1848a1c8ac1d bagit.txt +8854f01854401499b0304345cdfec9ea6b64d7f3 chipmunk-info.txt +ea0b21f9502fc9155bc065faaab275fe9dfd637f manifest-md5.txt +0f1e283905e1e0c5082ce6b9dd34e8c3d589b9ce manifest-sha1.txt +da39a3ee5e6b4b0d3255bfef95601890afd80709 meta.xml