001/* 002 * The contents of this file are subject to the license and copyright detailed 003 * in the LICENSE and NOTICE files at the root of the source tree. 004 */ 005package org.duraspace.bagit; 006 007import static java.util.function.Function.identity; 008import static org.assertj.core.api.Assertions.assertThat; 009import static org.duraspace.bagit.BagConfig.ACCESS_KEY; 010import static org.duraspace.bagit.BagConfig.BAGGING_DATE_KEY; 011import static org.duraspace.bagit.BagConfig.BAG_SIZE_KEY; 012import static org.duraspace.bagit.BagConfig.CONTACT_EMAIL_KEY; 013import static org.duraspace.bagit.BagConfig.CONTACT_NAME_KEY; 014import static org.duraspace.bagit.BagConfig.CONTACT_PHONE_KEY; 015import static org.duraspace.bagit.BagConfig.EXTERNAL_DESCRIPTION_KEY; 016import static org.duraspace.bagit.BagConfig.ORGANIZATION_ADDRESS_KEY; 017import static org.duraspace.bagit.BagConfig.PAYLOAD_OXUM_KEY; 018import static org.duraspace.bagit.BagConfig.SOURCE_ORGANIZATION_KEY; 019import static org.duraspace.bagit.BagConfig.TITLE_KEY; 020import static org.duraspace.bagit.BagProfileConstants.ACCEPT_BAGIT_VERSION; 021import static org.duraspace.bagit.BagProfileConstants.ACCEPT_SERIALIZATION; 022import static org.duraspace.bagit.BagProfileConstants.BAGIT_PROFILE_IDENTIFIER; 023import static org.duraspace.bagit.BagProfileConstants.BAGIT_PROFILE_INFO; 024import static org.duraspace.bagit.BagProfileConstants.BAGIT_PROFILE_VERSION; 025import static org.duraspace.bagit.BagProfileConstants.BAG_INFO; 026import static org.duraspace.bagit.BagProfileConstants.MANIFESTS_REQUIRED; 027import static org.duraspace.bagit.BagProfileConstants.PROFILE_VERSION; 028import static org.duraspace.bagit.BagProfileConstants.TAG_FILES_REQUIRED; 029import static org.duraspace.bagit.BagProfileConstants.TAG_MANIFESTS_REQUIRED; 030import static org.junit.Assert.assertEquals; 031import static org.junit.Assert.assertFalse; 032import static org.junit.Assert.assertNotNull; 033import static org.junit.Assert.assertTrue; 034 035import java.io.IOException; 036import java.net.URISyntaxException; 037import java.net.URL; 038import java.nio.file.Files; 039import java.nio.file.Path; 040import java.nio.file.Paths; 041import java.util.Arrays; 042import java.util.Collection; 043import java.util.Collections; 044import java.util.List; 045import java.util.Map; 046import java.util.Objects; 047import java.util.Set; 048import java.util.stream.Collectors; 049 050import gov.loc.repository.bagit.domain.Bag; 051import gov.loc.repository.bagit.domain.FetchItem; 052import gov.loc.repository.bagit.domain.Manifest; 053import gov.loc.repository.bagit.domain.Version; 054import gov.loc.repository.bagit.hash.StandardSupportedAlgorithms; 055import org.junit.Assert; 056import org.junit.Before; 057import org.junit.Test; 058import org.slf4j.Logger; 059import org.slf4j.LoggerFactory; 060 061/** 062 * @author escowles 063 * @since 2016-12-13 064 */ 065public class BagProfileTest { 066 067 private final String testValue = "test-value"; 068 private final String defaultBag = "bag"; 069 070 // profile locations 071 private final String defaultProfilePath = "profiles/profile.json"; 072 private final String extraTagsPath = "profiles/profileWithExtraTags.json"; 073 private final String invalidPath = "profiles/invalidProfile.json"; 074 private final String invalidSerializationPath = "profiles/invalidProfileSerializationError.json"; 075 076 // config locations 077 private final String bagitConfig = "configs/bagit-config.yml"; 078 private final String bagitConfigBadAccess = "configs/bagit-config-bad-access.yml"; 079 private final String bagitConfigMissingAccess = "configs/bagit-config-missing-access.yml"; 080 private final String bagitConfigNoAptrust = "configs/bagit-config-no-aptrust.yml"; 081 082 private final Version defaultVersion = new Version(1, 0); 083 private Path targetDir; 084 085 private final Logger logger = LoggerFactory.getLogger(BagProfileTest.class); 086 087 @Before 088 public void setup() throws URISyntaxException { 089 final URL url = this.getClass().getClassLoader().getResource("sample"); 090 targetDir = Paths.get(Objects.requireNonNull(url).toURI()); 091 } 092 093 @Test 094 public void testBuiltInProfiles() { 095 // validate that the string passed in the constructor matches the string used in the switch statement 096 for (BagProfile.BuiltIn value : BagProfile.BuiltIn.values()) { 097 assertEquals(value, BagProfile.BuiltIn.from(value.getIdentifier())); 098 } 099 } 100 101 @Test(expected = IllegalArgumentException.class) 102 public void testBuiltInNotSupported() { 103 BagProfile.BuiltIn.from("test"); 104 } 105 106 @Test 107 public void testBasicProfileFromFile() throws Exception { 108 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(defaultProfilePath))); 109 110 final String md5 = BagItDigest.MD5.bagitName(); 111 final String sha1 = BagItDigest.SHA1.bagitName(); 112 final String sha256 = BagItDigest.SHA256.bagitName(); 113 final String sha512 = BagItDigest.SHA512.bagitName(); 114 assertTrue(profile.getPayloadDigestAlgorithms().contains(md5)); 115 assertTrue(profile.getPayloadDigestAlgorithms().contains(sha1)); 116 assertTrue(profile.getPayloadDigestAlgorithms().contains(sha256)); 117 assertTrue(profile.getPayloadDigestAlgorithms().contains(sha512)); 118 119 assertFalse(profile.getTagDigestAlgorithms().contains(md5)); 120 assertTrue(profile.getTagDigestAlgorithms().contains(sha1)); 121 assertTrue(profile.getTagDigestAlgorithms().contains(sha256)); 122 assertTrue(profile.getTagDigestAlgorithms().contains(sha512)); 123 124 assertTrue(profile.getMetadataFields().get(SOURCE_ORGANIZATION_KEY).isRequired()); 125 assertFalse(profile.getMetadataFields().get(SOURCE_ORGANIZATION_KEY).isRepeatable()); 126 assertEquals(profile.getMetadataFields().get(SOURCE_ORGANIZATION_KEY).getDescription(), 127 SOURCE_ORGANIZATION_KEY); 128 assertTrue(profile.getMetadataFields().get(ORGANIZATION_ADDRESS_KEY).isRequired()); 129 assertTrue(profile.getMetadataFields().get(ORGANIZATION_ADDRESS_KEY).isRepeatable()); 130 assertTrue(profile.getMetadataFields().get(CONTACT_NAME_KEY).isRequired()); 131 assertTrue(profile.getMetadataFields().get(CONTACT_PHONE_KEY).isRequired()); 132 assertTrue(profile.getMetadataFields().get(BAG_SIZE_KEY).isRequired()); 133 assertTrue(profile.getMetadataFields().get(BAGGING_DATE_KEY).isRequired()); 134 assertTrue(profile.getMetadataFields().get(PAYLOAD_OXUM_KEY).isRequired()); 135 assertFalse(profile.getMetadataFields().get(CONTACT_EMAIL_KEY).isRequired()); 136 137 assertTrue(profile.getMetadataFields().get(BAGIT_PROFILE_IDENTIFIER).isRepeatable()); 138 assertFalse(profile.getMetadataFields().get(BAGIT_PROFILE_IDENTIFIER).isRequired()); 139 assertFalse(profile.getMetadataFields().get(BAGIT_PROFILE_IDENTIFIER).isRecommended()); 140 assertEquals(profile.getMetadataFields().get(BAGIT_PROFILE_IDENTIFIER).getDescription(), "No description"); 141 142 assertTrue(profile.getSectionNames().stream().allMatch(t -> t.equalsIgnoreCase(BAG_INFO))); 143 144 assertFalse(profile.isAllowFetch()); 145 assertEquals(BagProfile.Serialization.OPTIONAL, profile.getSerialization()); 146 assertTrue(profile.getAcceptedBagItVersions().contains("0.97")); 147 assertTrue(profile.getAcceptedBagItVersions().contains("1.0")); 148 assertTrue(profile.getAcceptedSerializations().contains("application/tar")); 149 assertTrue(profile.getTagFilesAllowed().contains("*")); 150 assertTrue(profile.getTagFilesRequired().isEmpty()); 151 assertTrue(profile.getAllowedTagAlgorithms().isEmpty()); 152 assertTrue(profile.getAllowedPayloadAlgorithms().isEmpty()); 153 } 154 155 @Test 156 public void testLoadsEmptyMap() throws URISyntaxException, IOException { 157 final String profilePath = "profiles/profileNoBagInfo.json"; 158 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(profilePath))); 159 final Map<String, ProfileFieldRule> bagInfoFields = profile.getMetadataFields(BAG_INFO); 160 assertNotNull(bagInfoFields); 161 assertTrue(bagInfoFields.isEmpty()); 162 } 163 164 @Test 165 public void testExtendedProfile() throws Exception { 166 final String aptrustInfo = "APTrust-Info"; 167 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(extraTagsPath))); 168 169 assertTrue(profile.getSectionNames().stream().anyMatch(t -> t.equalsIgnoreCase(BAG_INFO))); 170 assertTrue(profile.getSectionNames().stream().anyMatch(t -> t.equalsIgnoreCase(aptrustInfo))); 171 assertTrue(profile.getSectionNames().stream().noneMatch(t -> t.equalsIgnoreCase("Wrong-Tags"))); 172 assertTrue(profile.getMetadataFields(aptrustInfo).containsKey(TITLE_KEY)); 173 assertTrue(profile.getMetadataFields(aptrustInfo).containsKey(ACCESS_KEY)); 174 assertTrue(profile.getMetadataFields(aptrustInfo).get(ACCESS_KEY).getValues().contains("Consortia")); 175 assertTrue(profile.getMetadataFields(aptrustInfo).get(ACCESS_KEY).getValues().contains("Institution")); 176 assertTrue(profile.getMetadataFields(aptrustInfo).get(ACCESS_KEY).getValues().contains("Restricted")); 177 } 178 179 180 @Test 181 public void testGoodConfig() throws Exception { 182 final BagConfig config = new BagConfig(resolveResourcePath(bagitConfig).toFile()); 183 final Map<String, Map<String, String>> configAsMap = 184 config.getTagFiles().stream() 185 .collect(Collectors.toMap(identity(), config::getFieldsForTagFile)); 186 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(extraTagsPath))); 187 profile.validateConfig(config); 188 profile.validateTagFiles(configAsMap); 189 } 190 191 @Test(expected = RuntimeException.class) 192 public void testBadAccessValue() throws Exception { 193 final BagConfig config = new BagConfig(resolveResourcePath(bagitConfigBadAccess).toFile()); 194 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(extraTagsPath))); 195 profile.validateConfig(config); 196 } 197 198 @Test(expected = RuntimeException.class) 199 public void testMissingAccessValue() throws Exception { 200 final BagConfig config = new BagConfig(resolveResourcePath(bagitConfigMissingAccess).toFile()); 201 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(extraTagsPath))); 202 profile.validateConfig(config); 203 } 204 205 @Test 206 public void testMissingSectionNotNeeded() throws Exception { 207 final BagConfig config = new BagConfig(resolveResourcePath(bagitConfigNoAptrust).toFile()); 208 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(defaultProfilePath))); 209 profile.validateConfig(config); 210 } 211 212 @Test(expected = RuntimeException.class) 213 public void testMissingSectionRequired() throws Exception { 214 final BagConfig config = new BagConfig(resolveResourcePath(bagitConfigNoAptrust).toFile()); 215 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(extraTagsPath))); 216 profile.validateConfig(config); 217 } 218 219 @Test 220 public void testAllProfilesPassValidation() throws IOException { 221 final Path profiles = Paths.get("src/main/resources/profiles"); 222 223 Files.list(profiles).forEach(path -> { 224 String profileIdentifier = path.getFileName().toString(); 225 profileIdentifier = profileIdentifier.substring(0, profileIdentifier.indexOf(".")); 226 logger.debug("Validating {}", profileIdentifier); 227 BagProfile profile = null; 228 try { 229 profile = new BagProfile(BagProfile.BuiltIn.from(profileIdentifier)); 230 } catch (IOException e) { 231 Assert.fail(e.getMessage()); 232 } 233 234 validateProfile(Objects.requireNonNull(profile)); 235 }); 236 237 } 238 239 @Test 240 public void testInvalidBagProfile() throws IOException, URISyntaxException { 241 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(invalidPath))); 242 try { 243 assertThat(profile.getIdentifier()).isEmpty(); 244 validateProfile(profile); 245 Assert.fail("Should throw an exception"); 246 } catch (RuntimeException e) { 247 final String message = e.getMessage(); 248 // check that the error message contains each failed section 249 Assert.assertTrue(message.contains(BAGIT_PROFILE_INFO)); 250 Assert.assertTrue(message.contains(BAGIT_PROFILE_IDENTIFIER)); 251 Assert.assertTrue(message.contains(ACCEPT_SERIALIZATION)); 252 Assert.assertTrue(message.contains(MANIFESTS_REQUIRED)); 253 Assert.assertTrue(message.contains(TAG_MANIFESTS_REQUIRED)); 254 Assert.assertTrue(message.contains(TAG_FILES_REQUIRED)); 255 Assert.assertTrue(message.contains(ACCEPT_BAGIT_VERSION)); 256 } 257 } 258 259 @Test 260 public void testInvalidBagProfileSerializationTypo() throws IOException, URISyntaxException { 261 final BagProfile profile = new BagProfile(Files.newInputStream(resolveResourcePath(invalidSerializationPath))); 262 try { 263 validateProfile(profile); 264 Assert.fail("Should throw an exception"); 265 } catch (RuntimeException e) { 266 final String message = e.getMessage(); 267 // check that the serialization field failed to parse 268 Assert.assertTrue(message.contains("Unknown Serialization")); 269 } 270 } 271 272 /** 273 * Validates this {@link BagProfile} according to the BagIt Profiles specification found at 274 * https://bagit-profiles.github.io/bagit-profiles-specification/ 275 * 276 * This checks the following fields: 277 * 278 * BagIt-Profile-Info 279 * Existence of the Source-Organization, External-Description, Version, BagIt-Profile-Identifier, and 280 * BagIt-Profile-Version fields 281 * 282 * Serialization 283 * Is equal to one of "forbidden", "required", or "optional" 284 * 285 * Accept-Serialization 286 * If serialization has a value of required or optional, at least one value is needed. 287 * 288 * Manifests-Allowed 289 * If specified, the {@link BagProfile#getPayloadDigestAlgorithms()} must be a subset of 290 * {@link BagProfile#getAllowedPayloadAlgorithms()} 291 * 292 * Tag-Manifests-Allowed 293 * If specified, the {@link BagProfile#getTagDigestAlgorithms()} must be a subset of 294 * {@link BagProfile#getAllowedTagAlgorithms()} 295 * 296 * Tag-Files-Allowed 297 * If specified, the {@link BagProfile#getTagFilesRequired()} must be a subset of 298 * {@link BagProfile#getTagFilesAllowed()}. If not specified, all tags must match the '*' glob 299 * 300 * Accept-BagIt-Version 301 * At least one version is required 302 */ 303 private void validateProfile(final BagProfile profile) { 304 final StringBuilder errors = new StringBuilder(); 305 306 // Bag-Profile-Info 307 final List<String> expectedInfoFields = Arrays.asList(SOURCE_ORGANIZATION_KEY, 308 EXTERNAL_DESCRIPTION_KEY, 309 PROFILE_VERSION, 310 BAGIT_PROFILE_IDENTIFIER, 311 BAGIT_PROFILE_VERSION); 312 final Map<String, String> bagInfo = profile.getProfileMetadata(); 313 for (final String expected : expectedInfoFields) { 314 if (!bagInfo.containsKey(expected)) { 315 if (errors.length() == 0) { 316 errors.append("Error(s) in BagIt-Profile-Info:\n"); 317 } 318 errors.append(" * Missing key ").append(expected).append("\n"); 319 } 320 } 321 322 // Serialization / Accept-Serialization 323 final BagProfile.Serialization serialization = profile.getSerialization(); 324 if (serialization == BagProfile.Serialization.REQUIRED || serialization == BagProfile.Serialization.OPTIONAL) { 325 if (profile.getAcceptedSerializations().isEmpty()) { 326 errors.append("Serialization value of ").append(serialization) 327 .append(" requires at least one value in the Accept-Serialization field!\n"); 328 } 329 } else if(serialization == BagProfile.Serialization.UNKNOWN) { 330 errors.append("Unknown Serialization value ").append(serialization) 331 .append(". Allowed values are forbidden, required, or optional.\n"); 332 } 333 334 // Manifests-Allowed / Manifests-Required 335 final Set<String> allowedPayloadAlgorithms = profile.getAllowedPayloadAlgorithms(); 336 final Set<String> payloadDigestAlgorithms = profile.getPayloadDigestAlgorithms(); 337 if (!(allowedPayloadAlgorithms.isEmpty() || isSubset(payloadDigestAlgorithms, allowedPayloadAlgorithms))) { 338 errors.append("Manifests-Required must be a subset of Manifests-Allowed!\n"); 339 } 340 341 // Tag-Manifests-Allowed / Tag-Manifests-Required 342 final Set<String> allowedTagAlgorithms = profile.getAllowedTagAlgorithms(); 343 final Set<String> tagDigestAlgorithms = profile.getTagDigestAlgorithms(); 344 if (!(allowedTagAlgorithms.isEmpty() || isSubset(tagDigestAlgorithms, allowedTagAlgorithms))) { 345 errors.append("Tag-Manifests-Required must be a subset of Tag-Manifests-Allowed!\n"); 346 } 347 348 // Tag-Files-Allowed / Tag-Files-Required 349 final Set<String> tagFilesAllowed = profile.getTagFilesAllowed(); 350 final Set<String> tagFilesRequired = profile.getTagFilesRequired(); 351 if (!(tagFilesAllowed.isEmpty() || isSubset(tagFilesRequired, tagFilesAllowed))) { 352 errors.append("Tag-Files-Required must be a subset of Tag-Files-Allowed!\n"); 353 } 354 355 if (profile.getAcceptedBagItVersions().isEmpty()) { 356 errors.append("Accept-BagIt-Version requires at least one entry!"); 357 } 358 359 if (errors.length() > 0) { 360 errors.insert(0, "Bag Profile json does not conform to BagIt Profiles specification! " + 361 "The following errors occurred:\n"); 362 throw new RuntimeException(errors.toString()); 363 } 364 } 365 366 /** 367 * Check to see if a collection (labelled as {@code subCollection}) is a subset of the {@code superCollection} 368 * 369 * @param subCollection the sub collection to iterate against and check if elements are contained within 370 * {@code superCollection} 371 * @param superCollection the super collection containing all the elements 372 * @param <T> the type of each collection 373 * @return true if all elements of {@code subCollection} are contained within {@code superCollection} 374 */ 375 private <T> boolean isSubset(final Collection<T> subCollection, final Collection<T> superCollection) { 376 for (T t : subCollection) { 377 if (!superCollection.contains(t)) { 378 return false; 379 } 380 } 381 382 return true; 383 } 384 385 @Test 386 public void testValidateBag() throws IOException, URISyntaxException { 387 final Bag bag = new Bag(); 388 bag.setVersion(defaultVersion); 389 bag.setRootDir(targetDir.resolve(defaultBag)); 390 final BagProfile bagProfile = new BagProfile(Files.newInputStream(resolveResourcePath(defaultProfilePath))); 391 392 putRequiredBagInfo(bag, bagProfile); 393 putRequiredManifests(bag.getTagManifests(), bagProfile.getTagDigestAlgorithms()); 394 putRequiredManifests(bag.getPayLoadManifests(), bagProfile.getPayloadDigestAlgorithms()); 395 putRequiredTags(bag, bagProfile); 396 397 bagProfile.validateBag(bag); 398 } 399 400 @Test 401 public void testValidateBagFailure() throws IOException { 402 final Long fetchLength = 0L; 403 final Path fetchFile = Paths.get("data/fetch.txt"); 404 final URL fetchUrl = new URL("http://localhost/data/fetch.txt"); 405 406 final Bag bag = new Bag(); 407 bag.setItemsToFetch(Collections.singletonList(new FetchItem(fetchUrl, fetchLength, fetchFile))); 408 bag.setVersion(new Version(0, 0)); 409 bag.setRootDir(targetDir.resolve(defaultBag)); 410 final BagProfile bagProfile = new BagProfile(BagProfile.BuiltIn.APTRUST); 411 412 putRequiredBagInfo(bag, bagProfile); 413 putRequiredManifests(bag.getPayLoadManifests(), bagProfile.getPayloadDigestAlgorithms()); 414 putRequiredTags(bag, bagProfile); 415 416 try { 417 bagProfile.validateBag(bag); 418 Assert.fail("Validation did not throw an exception"); 419 } catch (RuntimeException e) { 420 final String message = e.getMessage(); 421 Assert.assertTrue(message.contains("Profile does not allow a fetch.txt")); 422 Assert.assertTrue(message.contains("No tag manifest")); 423 Assert.assertTrue(message.contains("Required tag file \"aptrust-info.txt\" does not exist")); 424 Assert.assertTrue(message.contains("Could not read info from \"aptrust-info.txt\"")); 425 Assert.assertTrue(message.contains("BagIt version incompatible")); 426 427 Assert.assertFalse(message.contains("Missing tag manifest algorithm")); 428 } 429 } 430 431 /** 432 * Add required tag files to a Bag from a BagProfile 433 * 434 * @param bag the Bag 435 * @param bagProfile the BagProfile defining the required files 436 */ 437 private void putRequiredTags(final Bag bag, final BagProfile bagProfile) { 438 final List<String> tagManifestExpected = Arrays.asList("manifest-sha1.txt", "bag-info.txt", "bagit.txt"); 439 440 // Always populate with the files we expect to see 441 for (String expected : tagManifestExpected) { 442 final Path required = Paths.get(expected); 443 for (Manifest manifest : bag.getTagManifests()) { 444 manifest.getFileToChecksumMap().put(required, testValue); 445 } 446 } 447 448 for (String requiredTag : bagProfile.getTagFilesRequired()) { 449 final Path requiredPath = Paths.get(requiredTag); 450 for (Manifest manifest : bag.getTagManifests()) { 451 manifest.getFileToChecksumMap().put(requiredPath, testValue); 452 } 453 } 454 } 455 456 /** 457 * 458 * @param manifests the manifests to add algorithms to 459 * @param algorithms the algorithms to add 460 */ 461 private void putRequiredManifests(final Set<Manifest> manifests, final Set<String> algorithms) { 462 for (String algorithm : algorithms) { 463 manifests.add(new Manifest(StandardSupportedAlgorithms.valueOf(algorithm.toUpperCase()))); 464 } 465 } 466 467 /** 468 * 469 * @param bag the Bag to set info fields for 470 * @param profile the BagProfile defining the required info fields 471 */ 472 private void putRequiredBagInfo(final Bag bag, final BagProfile profile) { 473 final Map<String, ProfileFieldRule> bagInfoMeta = profile.getMetadataFields(BAG_INFO); 474 bag.getMetadata().add(BAGIT_PROFILE_IDENTIFIER, profile.getIdentifier()); 475 for (Map.Entry<String, ProfileFieldRule> entry : bagInfoMeta.entrySet()) { 476 if (entry.getValue().isRequired()) { 477 bag.getMetadata().add(entry.getKey(), testValue); 478 } 479 } 480 } 481 482 private Path resolveResourcePath(final String resource) throws URISyntaxException { 483 final URL url = this.getClass().getClassLoader().getResource(resource); 484 return Paths.get(Objects.requireNonNull(url).toURI()); 485 } 486 487}