package s3.website
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
import com.amazonaws.AmazonServiceException
import com.amazonaws.services.cloudfront.AmazonCloudFront
import com.amazonaws.services.cloudfront.model.{CreateInvalidationRequest, CreateInvalidationResult, TooManyInvalidationsInProgressException}
import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.model._
import org.apache.commons.codec.digest.DigestUtils._
import org.apache.commons.io.FileUtils
import org.apache.commons.io.FileUtils._
import org.mockito.Mockito._
import org.mockito.invocation.InvocationOnMock
import org.mockito.stubbing.Answer
import org.mockito.{ArgumentCaptor, Matchers, Mockito}
import org.specs2.mutable.{BeforeAfter, Specification}
import org.specs2.specification.Scope
import s3.website.CloudFront.CloudFrontSetting
import s3.website.UploadHelper.DELETE_NOTHING_MAGIC_WORD
import s3.website.Push.{CliArgs}
import s3.website.S3.S3Setting
import s3.website.model.Config.S3_website_yml
import s3.website.model.Ssg.automaticallySupportedSiteGenerators
import s3.website.model._
import scala.collection.JavaConversions._
import scala.concurrent.duration._
import scala.util.Random
class S3WebsiteSpec extends Specification {
"gzip: true" should {
"update a gzipped S3 object if the contents has changed" in new BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
setLocalFileWithContent(("index.html", "hello
"))
setS3Files(S3File("index.html", md5Hex("hello
")))
push
noUploadsOccurred must beTrue
}
"update a file if it has changed" in new BasicSetup {
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 BasicSetup {
setLocalFile("index.html")
push
sentPutObjectRequest.getKey must equalTo("index.html")
}
"delete files that are on S3 but not on local file system" in new BasicSetup {
setS3Files(S3File("old.html", md5Hex("old text
")))
push
sentDelete must equalTo("old.html")
}
"try again if the upload fails" in new BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
config = "cloudfront_distribution_id: EGM1J2JJX9Z"
setLocalFile("newfile.js")
push
noInvalidationsOccurred must beTrue
}
"not send CloudFront invalidation requests on redirect objects" in new BasicSetup {
config = """
|cloudfront_distribution_id: EGM1J2JJX9Z
|redirects:
| /index.php: index.html
""".stripMargin
push
noInvalidationsOccurred must beTrue
}
"retry CloudFront responds with TooManyInvalidationsInProgressException" in new BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
setLocalFiles("file.txt")
push must equalTo(0)
}
"be 1 if any of the uploads fails" in new BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
setCloudFrontAsInternallyBroken()
config = "cloudfront_distribution_id: EGM1J2JJX9Z"
setLocalFile("test.css")
setOutdatedS3Keys("test.css")
push must equalTo(1)
}
"be 0 if upload retry succeeds" in new BasicSetup {
setLocalFile("index.html")
uploadFailsAndThenSucceeds(howManyFailures = 1)
push must equalTo(0)
}
"be 1 if delete retry fails" in new BasicSetup {
setLocalFile("index.html")
uploadFailsAndThenSucceeds(howManyFailures = 6)
push must equalTo(1)
}
"be 1 if an object listing fails" in new BasicSetup {
setS3Files(S3File("old.html", md5Hex("old text
")))
objectListingFailsAndThenSucceeds(howManyFailures = 6)
push must equalTo(1)
}
}
"s3_website.yml file" should {
"never be uploaded" in new BasicSetup {
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 BasicSetup {
setLocalFile(".env")
push
noUploadsOccurred must beTrue
}
}
"exclude_from_upload: string" should {
"result in matching files not being uploaded" in new BasicSetup {
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 BasicSetup {
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 BasicSetup {
config = "ignore_on_server: logs"
setS3Files(S3File("logs/log.txt", ""))
push
noDeletesOccurred must beTrue
}
"support non-US-ASCII files" in new BasicSetup {
setS3Files(S3File("tags/笔记/test.html", ""))
config = "ignore_on_server: tags/笔记/test.html"
push
noDeletesOccurred must beTrue
}
}
"ignore_on_server: _DELETE_NOTHING_ON_THE_S3_BUCKET_" should {
"result in no files being deleted on the S3 bucket" in new BasicSetup {
config = s"""
|ignore_on_server: $DELETE_NOTHING_MAGIC_WORD
""".stripMargin
setS3Files(S3File("file.txt", ""))
push
noDeletesOccurred
}
}
"""
ignore_on_server:
- regex
- another_ignore
""" should {
"not delete the S3 objects that match the ignore value" in new BasicSetup {
config = """
|ignore_on_server:
| - .*txt
""".stripMargin
setS3Files(S3File("logs/log.txt", ""))
push
noDeletesOccurred must beTrue
}
"support non-US-ASCII files" in new BasicSetup {
setS3Files(S3File("tags/笔记/test.html", ""))
config = """
|ignore_on_server:
| - tags/笔记/test.html
""".stripMargin
push
noDeletesOccurred must beTrue
}
}
"site in config" should {
"let the user deploy a site from a custom location" in new CustomSiteDirectory with EmptySite with MockAWS with DefaultRunMode {
config = s"site: $siteDirectory"
setLocalFile(".vimrc")
new File(siteDirectory, ".vimrc").exists() must beTrue // Sanity check
siteDirectory must not equalTo workingDirectory // Sanity check
push
sentPutObjectRequest.getKey must equalTo(".vimrc")
}
"not override the --site command-line switch" in new BasicSetup {
config = s"site: dir-that-does-not-exist"
setLocalFile(".vimrc") // This creates a file in the directory into which the --site CLI arg points
push
sentPutObjectRequest.getKey must equalTo(".vimrc")
}
automaticallySupportedSiteGenerators foreach { siteGenerator =>
"override an automatically detected site" in new CustomSiteDirectory with EmptySite with MockAWS with DefaultRunMode {
addContentToAutomaticallyDetectedSite(workingDirectory)
config = s"site: $siteDirectory"
setLocalFile(".vimrc") // Add content to the custom site directory
push
sentPutObjectRequest.getKey must equalTo(".vimrc")
}
def addContentToAutomaticallyDetectedSite(workingDirectory: File) {
val automaticallyDetectedSiteDir = new File(workingDirectory, siteGenerator.outputDirectory)
automaticallyDetectedSiteDir.mkdirs()
write(new File(automaticallyDetectedSiteDir, ".bashrc"), "echo hello")
}
}
}
"max-age in config" can {
"be applied to all files" in new BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
config = """
|max_age:
| "*.js": 90
""".stripMargin
setLocalFile("index.html")
push
sentPutObjectRequest.getMetadata.getCacheControl must beNull
}
"be used to disable caching" in new BasicSetup {
config = "max_age: 0"
setLocalFile("index.html")
push
sentPutObjectRequest.getMetadata.getCacheControl must equalTo("no-cache; max-age=0")
}
"support non-US-ASCII directory names" in new BasicSetup {
config = """
|max_age:
| "*": 21600
""".stripMargin
setLocalFile("tags/笔记/index.html")
push must equalTo(0)
}
}
"max-age in config" should {
"respect the more specific glob" in new BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
config = """
|redirects:
| index.php: /index.html
""".stripMargin
push
sentPutObjectRequest.getRedirectLocation must equalTo("/index.html")
}
"add slash to the redirect target" in new BasicSetup {
config = """
|redirects:
| index.php: index.html
""".stripMargin
push
sentPutObjectRequest.getRedirectLocation must equalTo("/index.html")
}
"support external redirects" in new BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
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 BasicSetup {
setLocalFile(".vimrc")
push
sentPutObjectRequest.getKey must equalTo(".vimrc")
}
}
"content type inference" should {
"add charset=utf-8 to all html documents" in new BasicSetup {
setLocalFile("index.html")
push
sentPutObjectRequest.getMetadata.getContentType must equalTo("text/html; charset=utf-8")
}
"add charset=utf-8 to all text documents" in new BasicSetup {
setLocalFile("index.txt")
push
sentPutObjectRequest.getMetadata.getContentType must equalTo("text/plain; charset=utf-8")
}
"add charset=utf-8 to all json documents" in new BasicSetup {
setLocalFile("data.json")
push
sentPutObjectRequest.getMetadata.getContentType must equalTo("application/json; charset=utf-8")
}
"resolve the content type from file contents" in new BasicSetup {
setLocalFileWithContent(("index", "hi
"))
push
sentPutObjectRequest.getMetadata.getContentType must equalTo("text/html; charset=utf-8")
}
}
"ERB in config file" should {
"be evaluated" in new BasicSetup {
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
}
}
"push --force" should {
"push all the files whether they have changed or not" in new ForcePush {
setLocalFileWithContent(("index.html", "hi
"))
setS3Files(S3File("index.html", "1c5117e5839ad8fc00ce3c41296255a1" /* md5 of the gzip of the file contents */))
push
sentPutObjectRequest.getKey must equalTo("index.html")
}
}
"dry run" should {
"not push updates" in new DryRun {
setLocalFileWithContent(("index.html", "new
"))
setS3Files(S3File("index.html", md5Hex("old
")))
push
noUploadsOccurred must beTrue
}
"not push redirects" in new DryRun {
config =
"""
|redirects:
| index.php: /index.html
""".stripMargin
push
noUploadsOccurred must beTrue
}
"not push deletes" in new DryRun {
setS3Files(S3File("index.html", md5Hex("old
")))
push
noUploadsOccurred must beTrue
}
"not push new files" in new DryRun {
setLocalFile("index.html")
push
noUploadsOccurred must beTrue
}
"not invalidate files" in new DryRun {
config = "cloudfront_invalidation_id: AABBCC"
setS3Files(S3File("index.html", md5Hex("old
")))
push
noInvalidationsOccurred must beTrue
}
}
"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 BasicSetup extends SiteLocationFromCliArg with EmptySite with MockAWS with DefaultRunMode
trait ForcePush extends SiteLocationFromCliArg with EmptySite with MockAWS with ForcePushMode
trait DryRun extends SiteLocationFromCliArg with EmptySite with MockAWS with DryRunMode
trait DefaultRunMode {
implicit def pushOptions: PushOptions = new PushOptions {
def dryRun = false
def force = false
}
}
trait DryRunMode {
implicit def pushOptions: PushOptions = new PushOptions {
def dryRun = true
def force = false
}
}
trait ForcePushMode {
implicit def pushOptions: PushOptions = new PushOptions {
def dryRun = false
def force = 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 {
def randomDir() = new File(FileUtils.getTempDirectory, "s3_website_dir" + Random.nextLong())
implicit final val workingDirectory: File = randomDir()
implicit def yamlConfig: S3_website_yml = S3_website_yml(new File(workingDirectory, "s3_website.yml"))
val siteDirectory: File
val configDirectory: File = workingDirectory // Represents the --config-dir=X option
def before {
workingDirectory :: siteDirectory :: configDirectory :: Nil foreach forceMkdir
}
def after {
(workingDirectory :: siteDirectory :: configDirectory :: Nil) foreach { dir =>
if (dir.exists) forceDelete(dir)
}
}
}
trait SiteLocationFromCliArg extends Directories {
val siteDirectory = workingDirectory
val siteDirFromCLIArg = true
}
trait JekyllSite extends Directories {
val siteDirectory = new File(workingDirectory, "_site")
val siteDirFromCLIArg = false
}
trait NanocSite extends Directories {
val siteDirectory = new File(workingDirectory, "public/output")
val siteDirFromCLIArg = false
}
trait CustomSiteDirectory extends Directories {
val siteDirectory = randomDir()
val siteDirFromCLIArg = false
}
trait EmptySite extends Directories {
val siteDirFromCLIArg: Boolean
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 configString: ConfigString =
ConfigString(
s"""
|$baseConfig
|$config
""".stripMargin
)
def pushOptions: PushOptions
implicit def cliArgs: CliArgs =
new CliArgs {
def verbose = true
def dryRun = pushOptions.dryRun
def site = if (siteDirFromCLIArg) siteDirectory.getAbsolutePath else null
def configDir = configDirectory.getAbsolutePath
def force = pushOptions.force
}
}
def push(implicit
emptyYamlConfig: S3_website_yml,
configString: ConfigString,
cliArgs: CliArgs,
s3Settings: S3Setting,
cloudFrontSettings: CloudFrontSetting,
workingDirectory: File) = {
write(emptyYamlConfig.file, configString.yaml) // Write the yaml config lazily, so that the tests can override the default yaml config
Push.push
}
case class ConfigString(yaml: String)
}