package s3.website import org.specs2.mutable.{BeforeAfter, After, Specification} import s3.website.model._ import org.specs2.specification.Scope import org.apache.commons.io.FileUtils import java.io.File import scala.util.Random import org.mockito.{Mockito, Matchers, ArgumentCaptor} import org.mockito.Mockito._ import com.amazonaws.services.s3.AmazonS3 import com.amazonaws.services.s3.model._ import scala.concurrent.duration._ import s3.website.S3.S3Setting import scala.collection.JavaConversions._ import com.amazonaws.AmazonServiceException import org.apache.commons.codec.digest.DigestUtils._ import s3.website.CloudFront.CloudFrontSetting import com.amazonaws.services.cloudfront.AmazonCloudFront import com.amazonaws.services.cloudfront.model.{CreateInvalidationResult, CreateInvalidationRequest, TooManyInvalidationsInProgressException} import org.mockito.stubbing.Answer import org.mockito.invocation.InvocationOnMock import java.util.concurrent.atomic.AtomicInteger import org.apache.commons.io.FileUtils._ import scala.collection.mutable import s3.website.Push.{push, CliArgs} import s3.website.CloudFront.CloudFrontSetting import s3.website.S3.S3Setting import org.apache.commons.codec.digest.DigestUtils import java.util.Date import s3.website.CloudFront.CloudFrontSetting import s3.website.S3.S3Setting class S3WebsiteSpec extends Specification { "gzip: true" should { "update a gzipped S3 object if the contents has changed" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "gzip: true" setLocalFileWithContent(("styles.css", "

hi again

")) setS3Files(S3File("styles.css", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)) push sentPutObjectRequest.getKey must equalTo("styles.css") } "not update a gzipped S3 object if the contents has not changed" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "gzip: true" setLocalFileWithContent(("styles.css", "

hi

")) setS3Files(S3File("styles.css", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)) push noUploadsOccurred must beTrue } } """ gzip: - .xml """ should { "update a gzipped S3 object if the contents has changed" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |gzip: | - .xml """.stripMargin setLocalFileWithContent(("file.xml", "

hi again

")) setS3Files(S3File("file.xml", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */)) push sentPutObjectRequest.getKey must equalTo("file.xml") } } "push" should { "not upload a file if it has not changed" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFileWithContent(("index.html", "
hello
")) setS3Files(S3File("index.html", md5Hex("
hello
"))) push noUploadsOccurred must beTrue } "update a file if it has changed" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFileWithContent(("index.html", "

old text

")) setS3Files(S3File("index.html", md5Hex("

new text

"))) push sentPutObjectRequest.getKey must equalTo("index.html") } "create a file if does not exist on S3" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") push sentPutObjectRequest.getKey must equalTo("index.html") } "delete files that are on S3 but not on local file system" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setS3Files(S3File("old.html", md5Hex("

old text

"))) push sentDelete must equalTo("old.html") } "try again if the upload fails" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") uploadFailsAndThenSucceeds(howManyFailures = 5) push verify(amazonS3Client, times(6)).putObject(Matchers.any(classOf[PutObjectRequest])) } "not try again if the upload fails on because of invalid credentials" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow { val e = new AmazonServiceException("your credentials are incorrect") e.setStatusCode(403) e } push verify(amazonS3Client, times(1)).putObject(Matchers.any(classOf[PutObjectRequest])) } "try again if the request times out" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { var attempt = 0 when(amazonS3Client putObject Matchers.any(classOf[PutObjectRequest])) thenAnswer new Answer[PutObjectResult] { def answer(invocation: InvocationOnMock) = { attempt += 1 if (attempt < 2) { val e = new AmazonServiceException("Too long a request") e.setStatusCode(400) e.setErrorCode("RequestTimeout") throw e } else { new PutObjectResult } } } setLocalFile("index.html") val exitStatus = push verify(amazonS3Client, times(2)).putObject(Matchers.any(classOf[PutObjectRequest])) } "try again if the delete fails" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setS3Files(S3File("old.html", md5Hex("

old text

"))) deleteFailsAndThenSucceeds(howManyFailures = 5) push verify(amazonS3Client, times(6)).deleteObject(Matchers.anyString(), Matchers.anyString()) } "try again if the object listing fails" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setS3Files(S3File("old.html", md5Hex("

old text

"))) objectListingFailsAndThenSucceeds(howManyFailures = 5) push verify(amazonS3Client, times(6)).listObjects(Matchers.any(classOf[ListObjectsRequest])) } } "push with CloudFront" should { "invalidate the updated CloudFront items" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFiles("css/test.css", "articles/index.html") setOutdatedS3Keys("css/test.css", "articles/index.html") push sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/css/test.css" :: "/articles/index.html" :: Nil).sorted) } "not send CloudFront invalidation requests on new objects" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("newfile.js") push noInvalidationsOccurred must beTrue } "not send CloudFront invalidation requests on redirect objects" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |cloudfront_distribution_id: EGM1J2JJX9Z |redirects: | /index.php: index.html """.stripMargin push noInvalidationsOccurred must beTrue } "retry CloudFront responds with TooManyInvalidationsInProgressException" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setTooManyInvalidationsInProgress(4) config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("test.css") setOutdatedS3Keys("test.css") push must equalTo(0) // The retries should finally result in a success sentInvalidationRequests.length must equalTo(4) } "retry if CloudFront is temporarily unreachable" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { invalidationsFailAndThenSucceed(5) config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("test.css") setOutdatedS3Keys("test.css") push sentInvalidationRequests.length must equalTo(6) } "encode unsafe characters in the keys" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("articles/arnold's file.html") setOutdatedS3Keys("articles/arnold's file.html") push sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/articles/arnold's%20file.html" :: Nil).sorted) } "invalidate the root object '/' if a top-level object is updated or deleted" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("maybe-index.html") setOutdatedS3Keys("maybe-index.html") push sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/" :: "/maybe-index.html" :: Nil).sorted) } } "cloudfront_invalidate_root: true" should { "convert CloudFront invalidation paths with the '/index.html' suffix into '/'" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |cloudfront_distribution_id: EGM1J2JJX9Z |cloudfront_invalidate_root: true """.stripMargin setLocalFile("articles/index.html") setOutdatedS3Keys("articles/index.html") push sentInvalidationRequest.getInvalidationBatch.getPaths.getItems.toSeq.sorted must equalTo(("/articles/" :: Nil).sorted) } } "a site with over 1000 items" should { "split the CloudFront invalidation requests into batches of 1000 items" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { val files = (1 to 1002).map { i => s"lots-of-files/file-$i"} config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFiles(files:_*) setOutdatedS3Keys(files:_*) push sentInvalidationRequests.length must equalTo(2) sentInvalidationRequests(0).getInvalidationBatch.getPaths.getItems.length must equalTo(1000) sentInvalidationRequests(1).getInvalidationBatch.getPaths.getItems.length must equalTo(2) } } "push exit status" should { "be 0 all uploads succeed" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFiles("file.txt") push must equalTo(0) } "be 1 if any of the uploads fails" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFiles("file.txt") when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow(new AmazonServiceException("AWS failed")) push must equalTo(1) } "be 1 if any of the redirects fails" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |redirects: | index.php: /index.html """.stripMargin when(amazonS3Client.putObject(Matchers.any(classOf[PutObjectRequest]))).thenThrow(new AmazonServiceException("AWS failed")) push must equalTo(1) } "be 0 if CloudFront invalidations and uploads succeed"in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("test.css") setOutdatedS3Keys("test.css") push must equalTo(0) } "be 1 if CloudFront is unreachable or broken"in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setCloudFrontAsInternallyBroken() config = "cloudfront_distribution_id: EGM1J2JJX9Z" setLocalFile("test.css") setOutdatedS3Keys("test.css") push must equalTo(1) } "be 0 if upload retry succeeds" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") uploadFailsAndThenSucceeds(howManyFailures = 1) push must equalTo(0) } "be 1 if delete retry fails" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") uploadFailsAndThenSucceeds(howManyFailures = 6) push must equalTo(1) } "be 1 if an object listing fails" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setS3Files(S3File("old.html", md5Hex("

old text

"))) objectListingFailsAndThenSucceeds(howManyFailures = 6) push must equalTo(1) } } "s3_website.yml file" should { "never be uploaded" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile("s3_website.yml") push noUploadsOccurred must beTrue } } ".env file" should { // The .env file is the https://github.com/bkeepers/dotenv file "never be uploaded" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile(".env") push noUploadsOccurred must beTrue } } "exclude_from_upload: string" should { "result in matching files not being uploaded" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "exclude_from_upload: .DS_.*?" setLocalFile(".DS_Store") push noUploadsOccurred must beTrue } } """ exclude_from_upload: - regex - another_exclusion """ should { "result in matching files not being uploaded" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |exclude_from_upload: | - .DS_.*? | - logs """.stripMargin setLocalFiles(".DS_Store", "logs/test.log") push noUploadsOccurred must beTrue } } "ignore_on_server: value" should { "not delete the S3 objects that match the ignore value" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "ignore_on_server: logs" setS3Files(S3File("logs/log.txt", "")) push noDeletesOccurred must beTrue } } """ ignore_on_server: - regex - another_ignore """ should { "not delete the S3 objects that match the ignore value" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |ignore_on_server: | - .*txt """.stripMargin setS3Files(S3File("logs/log.txt", "")) push noDeletesOccurred must beTrue } } "max-age in config" can { "be applied to all files" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "max_age: 60" setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=60") } "be applied to files that match the glob" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |max_age: | "*.html": 90 """.stripMargin setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90") } "be applied to directories that match the glob" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |max_age: | "assets/**/*.js": 90 """.stripMargin setLocalFile("assets/lib/jquery.js") push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=90") } "not be applied if the glob doesn't match" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |max_age: | "*.js": 90 """.stripMargin setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getCacheControl must beNull } "be used to disable caching" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "max_age: 0" setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("no-cache; max-age=0") } } "max-age in config" should { "respect the more specific glob" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |max_age: | "assets/*": 150 | "assets/*.gif": 86400 """.stripMargin setLocalFiles("assets/jquery.js", "assets/picture.gif") push sentPutObjectRequests.find(_.getKey == "assets/jquery.js").get.getMetadata.getCacheControl must equalTo("max-age=150") sentPutObjectRequests.find(_.getKey == "assets/picture.gif").get.getMetadata.getCacheControl must equalTo("max-age=86400") } } "s3_reduced_redundancy: true in config" should { "result in uploads being marked with reduced redundancy" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "s3_reduced_redundancy: true" setLocalFile("file.exe") push sentPutObjectRequest.getStorageClass must equalTo("REDUCED_REDUNDANCY") } } "s3_reduced_redundancy: false in config" should { "result in uploads being marked with the default storage class" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = "s3_reduced_redundancy: false" setLocalFile("file.exe") push sentPutObjectRequest.getStorageClass must beNull } } "redirect in config" should { "result in a redirect instruction that is sent to AWS" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |redirects: | index.php: /index.html """.stripMargin push sentPutObjectRequest.getRedirectLocation must equalTo("/index.html") } "add slash to the redirect target" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |redirects: | index.php: index.html """.stripMargin push sentPutObjectRequest.getRedirectLocation must equalTo("/index.html") } "support external redirects" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |redirects: | index.php: http://www.youtube.com/watch?v=dQw4w9WgXcQ """.stripMargin push sentPutObjectRequest.getRedirectLocation must equalTo("http://www.youtube.com/watch?v=dQw4w9WgXcQ") } "support external redirects that point to an HTTPS target" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |redirects: | index.php: https://www.youtube.com/watch?v=dQw4w9WgXcQ """.stripMargin push sentPutObjectRequest.getRedirectLocation must equalTo("https://www.youtube.com/watch?v=dQw4w9WgXcQ") } "result in max-age=0 Cache-Control header on the object" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |redirects: | index.php: /index.html """.stripMargin push sentPutObjectRequest.getMetadata.getCacheControl must equalTo("max-age=0, no-cache") } } "redirect in config and an object on the S3 bucket" should { "not result in the S3 object being deleted" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |redirects: | index.php: /index.html """.stripMargin setLocalFile("index.php") setS3Files(S3File("index.php", "md5")) push noDeletesOccurred must beTrue } } "dotfiles" should { "be included in the pushed files" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile(".vimrc") push sentPutObjectRequest.getKey must equalTo(".vimrc") } } "content type inference" should { "add charset=utf-8 to all html documents" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") push sentPutObjectRequest.getMetadata.getContentType must equalTo("text/html; charset=utf-8") } "add charset=utf-8 to all text documents" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.txt") push sentPutObjectRequest.getMetadata.getContentType must equalTo("text/plain; charset=utf-8") } "add charset=utf-8 to all json documents" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFile("data.json") push sentPutObjectRequest.getMetadata.getContentType must equalTo("application/json; charset=utf-8") } "resolve the content type from file contents" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFileWithContent(("index", "

hi

")) push sentPutObjectRequest.getMetadata.getContentType must equalTo("text/html; charset=utf-8") } } "ERB in config file" should { "be evaluated" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { config = """ |redirects: |<%= ('a'..'f').to_a.map do |t| ' '+t+ ': /'+t+'.html' end.join('\n')%> """.stripMargin push sentPutObjectRequests.length must equalTo(6) sentPutObjectRequests.forall(_.getRedirectLocation != null) must beTrue } } "dry run" should { "not push updates" in new AllInSameDirectory with EmptySite with MockAWS with DryRunMode { setLocalFileWithContent(("index.html", "
new
")) setS3Files(S3File("index.html", md5Hex("
old
"))) push noUploadsOccurred must beTrue } "not push redirects" in new AllInSameDirectory with EmptySite with MockAWS with DryRunMode { config = """ |redirects: | index.php: /index.html """.stripMargin push noUploadsOccurred must beTrue } "not push deletes" in new AllInSameDirectory with EmptySite with MockAWS with DryRunMode { setS3Files(S3File("index.html", md5Hex("
old
"))) push noUploadsOccurred must beTrue } "not push new files" in new AllInSameDirectory with EmptySite with MockAWS with DryRunMode { setLocalFile("index.html") push noUploadsOccurred must beTrue } "not invalidate files" in new AllInSameDirectory with EmptySite with MockAWS with DryRunMode { config = "cloudfront_invalidation_id: AABBCC" setS3Files(S3File("index.html", md5Hex("
old
"))) push noInvalidationsOccurred must beTrue } } // Because of the local database, the first and second run are implemented differently. "pushing files for the second time" should { "delete the S3 objects that no longer exist on the local site" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { push setS3Files(S3File("obsolete.txt", "")) push sentDelete must equalTo("obsolete.txt") } "delete the local db record for the file if the user deletes the file" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFileWithContent(("file.txt", "first run")) push setS3Files(S3File("file.txt", md5Hex("first run"))) FileUtils.deleteQuietly(new File(siteDirectory, "file.txt")) push FileUtils.readLines(localDatabase) must beEmpty } "push new files to the bucket" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { push setLocalFile("newfile.txt") push sentPutObjectRequest.getKey must equalTo("newfile.txt") } "push locally changed files" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFileWithContent(("file.txt", "first run")) push setLocalFileWithContent(("file.txt", "second run")) push sentPutObjectRequests.length must equalTo(2) } "push locally changed files only once" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFileWithContent(("file.txt", "first run")) push setS3Files(S3File("file.txt", md5Hex("first run"))) setLocalFileWithContent(("file.txt", "second run")) push sentPutObjectRequests.length must equalTo(2) } "detect files that someone else has changed on the S3 bucket" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFileWithContent(("file.txt", "first run")) push setOutdatedS3Keys("file.txt") push sentPutObjectRequests.length must equalTo(2) } "push locally unchanged files that are missing from S3" in new AllInSameDirectory with EmptySite with MockAWS with DefaultRunMode { setLocalFileWithContent(("file.txt", "first run")) push removeAllFilesFromS3() push sentPutObjectRequests.length must equalTo(2) // Even though we use the local db, we should notice that someone else has deleted file.txt } } "Jekyll site" should { "be detected automatically" in new JekyllSite with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") push sentPutObjectRequests.length must equalTo(1) } } "Nanoc site" should { "be detected automatically" in new NanocSite with EmptySite with MockAWS with DefaultRunMode { setLocalFile("index.html") push sentPutObjectRequests.length must equalTo(1) } } trait DefaultRunMode { implicit def pushMode: PushMode = new PushMode { def dryRun = false } } trait DryRunMode { implicit def pushMode: PushMode = new PushMode { def dryRun = true } } trait MockAWS extends MockS3 with MockCloudFront with Scope trait MockCloudFront extends MockAWSHelper { val amazonCloudFrontClient = mock(classOf[AmazonCloudFront]) implicit val cfSettings: CloudFrontSetting = CloudFrontSetting( cfClient = _ => amazonCloudFrontClient, retryTimeUnit = MICROSECONDS ) def sentInvalidationRequests: Seq[CreateInvalidationRequest] = { val createInvalidationReq = ArgumentCaptor.forClass(classOf[CreateInvalidationRequest]) verify(amazonCloudFrontClient, Mockito.atLeastOnce()).createInvalidation(createInvalidationReq.capture()) createInvalidationReq.getAllValues } def sentInvalidationRequest = sentInvalidationRequests.ensuring(_.length == 1).head def noInvalidationsOccurred = { verify(amazonCloudFrontClient, Mockito.never()).createInvalidation(Matchers.any(classOf[CreateInvalidationRequest])) true // Mockito is based on exceptions } def invalidationsFailAndThenSucceed(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) { doAnswer(temporaryFailure(classOf[CreateInvalidationResult])) .when(amazonCloudFrontClient) .createInvalidation(Matchers.anyObject()) } def setTooManyInvalidationsInProgress(attemptWhenInvalidationSucceeds: Int) { var callCount = 0 doAnswer(new Answer[CreateInvalidationResult] { override def answer(invocation: InvocationOnMock): CreateInvalidationResult = { callCount += 1 if (callCount < attemptWhenInvalidationSucceeds) throw new TooManyInvalidationsInProgressException("just too many, man") else mock(classOf[CreateInvalidationResult]) } }).when(amazonCloudFrontClient).createInvalidation(Matchers.anyObject()) } def setCloudFrontAsInternallyBroken() { when(amazonCloudFrontClient.createInvalidation(Matchers.anyObject())).thenThrow(new AmazonServiceException("CloudFront is down")) } } trait MockS3 extends MockAWSHelper { val amazonS3Client = mock(classOf[AmazonS3]) implicit val s3Settings: S3Setting = S3Setting( s3Client = _ => amazonS3Client, retryTimeUnit = MICROSECONDS ) val s3ObjectListing = new ObjectListing when(amazonS3Client.listObjects(Matchers.any(classOf[ListObjectsRequest]))).thenReturn(s3ObjectListing) def setOutdatedS3Keys(s3Keys: String*) { s3Keys .map(key => S3File(key, md5Hex(Random.nextLong().toString)) // Simulate the situation where the file on S3 is outdated (as compared to the local file) ) .foreach (setS3Files(_)) } def setS3Files(s3Files: S3File*) { s3Files.foreach { s3File => s3ObjectListing.getObjectSummaries.add({ val summary = new S3ObjectSummary summary.setETag(s3File.md5) summary.setKey(s3File.s3Key) summary }) } } def removeAllFilesFromS3() { setS3Files(Nil: _*) // This corresponds to the situation where the S3 bucket is empty } def uploadFailsAndThenSucceeds(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) { doAnswer(temporaryFailure(classOf[PutObjectResult])) .when(amazonS3Client) .putObject(Matchers.anyObject()) } def deleteFailsAndThenSucceeds(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) { doAnswer(temporaryFailure(classOf[DeleteObjectRequest])) .when(amazonS3Client) .deleteObject(Matchers.anyString(), Matchers.anyString()) } def objectListingFailsAndThenSucceeds(implicit howManyFailures: Int, callCount: AtomicInteger = new AtomicInteger(0)) { doAnswer(temporaryFailure(classOf[ObjectListing])) .when(amazonS3Client) .listObjects(Matchers.any(classOf[ListObjectsRequest])) } def sentPutObjectRequests: Seq[PutObjectRequest] = { val req = ArgumentCaptor.forClass(classOf[PutObjectRequest]) verify(amazonS3Client, Mockito.atLeast(1)).putObject(req.capture()) req.getAllValues } def sentPutObjectRequest = sentPutObjectRequests.ensuring(_.length == 1).head def sentDeletes: Seq[S3Key] = { val deleteKey = ArgumentCaptor.forClass(classOf[S3Key]) verify(amazonS3Client).deleteObject(Matchers.anyString(), deleteKey.capture()) deleteKey.getAllValues } def sentDelete = sentDeletes.ensuring(_.length == 1).head def noDeletesOccurred = { verify(amazonS3Client, never()).deleteObject(Matchers.anyString(), Matchers.anyString()) true // Mockito is based on exceptions } def noUploadsOccurred = { verify(amazonS3Client, never()).putObject(Matchers.any(classOf[PutObjectRequest])) true // Mockito is based on exceptions } type S3Key = String } trait MockAWSHelper { def temporaryFailure[T](clazz: Class[T])(implicit callCount: AtomicInteger, howManyFailures: Int) = new Answer[T] { def answer(invocation: InvocationOnMock) = { callCount.incrementAndGet() if (callCount.get() <= howManyFailures) throw new AmazonServiceException("AWS is temporarily down") else mock(clazz) } } } trait Directories extends BeforeAfter { implicit final val workingDirectory: File = new File(FileUtils.getTempDirectory, "s3_website_dir" + Random.nextLong()) val siteDirectory: File // Represents the --site=X option val configDirectory: File = workingDirectory // Represents the --config-dir=X option lazy val localDatabase: File = new File(FileUtils.getTempDirectory, "s3_website_local_db_" + sha256Hex(siteDirectory.getPath)) def before { workingDirectory :: siteDirectory :: configDirectory :: Nil foreach forceMkdir } def after { (workingDirectory :: siteDirectory :: configDirectory :: localDatabase :: Nil) foreach { dir => if (dir.exists) forceDelete(dir) } } } /** * Represents the situation where the current working directory, site dir and config dir are in the same directory */ trait AllInSameDirectory extends Directories { val siteDirectory = workingDirectory } trait JekyllSite extends Directories { val siteDirectory = new File(workingDirectory, "_site") } trait NanocSite extends Directories { val siteDirectory = new File(workingDirectory, "public/output") } trait EmptySite extends Directories { type LocalFileWithContent = (String, String) def setLocalFile(fileName: String) = setLocalFileWithContent((fileName, "")) def setLocalFiles(fileNames: String*) = fileNames foreach setLocalFile def setLocalFilesWithContent(fileNamesAndContent: LocalFileWithContent*) = fileNamesAndContent foreach setLocalFileWithContent def setLocalFileWithContent(fileNameAndContent: LocalFileWithContent) = { val file = new File(siteDirectory, fileNameAndContent._1) forceMkdir(file.getParentFile) file.createNewFile() write(file, fileNameAndContent._2) } var config = "" val baseConfig = """ |s3_id: foo |s3_secret: bar |s3_bucket: bucket """.stripMargin implicit def cliArgs: CliArgs = buildCliArgs(config) def pushMode: PushMode // Represents the --dry-run switch private def buildCliArgs( config: String = "", baseConfig: String = """ |s3_id: foo |s3_secret: bar |s3_bucket: bucket """.stripMargin ): CliArgs = { val configFile = new File(configDirectory, "s3_website.yml") write(configFile, s""" |$baseConfig |$config """.stripMargin ) new CliArgs { override def verbose = true override def dryRun = pushMode.dryRun override def site = siteDirectory.getAbsolutePath override def configDir = configDirectory.getAbsolutePath } } } }