packages feed

aws-general-0.1: tests/SignatureV4.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE FlexibleInstances #-}

-- |
-- Module: SignatureV4
-- Copyright: Copyright © 2014 AlephCloud Systems, Inc.
-- License: MIT
-- Maintainer: Lars Kuhtz <lars@alephcloud.com>
-- Stability: experimental
--
-- Unit tests for "Aws.SignatureV4"
--
module SignatureV4
( tests

-- * Test Properties
, prop_canonicalHeaders
, allTests

) where

import General (prop_textRoundtrip)

import Aws.Core
import Aws.General
import Aws.SignatureV4

import Control.Applicative
import Control.Error.Util
import Control.Error (initSafe)
import Control.Exception
import Control.Monad
import Control.Monad.Trans.Either
import Control.Monad.IO.Class

import qualified Data.Attoparsec.ByteString.Char8 as A8
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as B8
import qualified Data.CaseInsensitive as CI
import Data.Function (on)
import qualified Data.List as L
import Data.Maybe
import Data.Monoid
import Data.String
import qualified Data.Text.Encoding as T
import Data.Time.Clock (UTCTime)
import Data.Tagged

import qualified Network.HTTP.Types as HTTP

import qualified Test.QuickCheck as Q
import Test.QuickCheck.Instances ()

import qualified Text.Parser.Char as P
import qualified Text.Parser.Combinators as P

import System.Directory
import System.IO.Unsafe (unsafePerformIO)

import Test.Tasty.QuickCheck
import Test.Tasty
import Test.Tasty.Providers

tests :: TestTree
tests = testGroup "SignatureV4"
    $ quickCheckTests
    : if dateNormalizationEnabled then [] else [awsSignatureV4TestSuite]

-- -------------------------------------------------------------------------- --
-- QuickCheck Properties

quickCheckTests :: TestTree
quickCheckTests = testGroup "quick queck tests"
    [ testProperty "canonical headers" prop_canonicalHeaders
    , testProperty "text roundtrip for CredentialScope"
        (prop_textRoundtrip :: CredentialScope -> Bool)
    ]

instance Q.Arbitrary (CI.CI B.ByteString) where
    arbitrary = CI.mk . T.encodeUtf8 <$> Q.arbitrary

prop_canonicalHeaders :: HTTP.RequestHeaders -> Bool
prop_canonicalHeaders h = let x = canonicalHeaders h in x `seq` True

-- -------------------------------------------------------------------------- --
-- AWS Signature V4 Test Suite
--
-- <http://docs.aws.amazon.com/general/1.0/gr/samples/aws4_testsuite.zip>
--
-- TODO use machinery from tasty-golden to run these tests
--

instance IsTest (IO Bool) where
    run _ t _ = t >>= \x -> return $ if x
        then testPassed ""
        else testFailed ""
    testOptions = Tagged []

simpleIOTest :: String -> IO Bool -> TestTree
simpleIOTest = singleTest

--

awsSignatureV4TestSuite :: TestTree
awsSignatureV4TestSuite = simpleIOTest "AWS Signature V4 Test Suite" allTests

baseDir :: String
baseDir = "./tests/signature-v4/aws4_testsuite"

testFileBase :: String -> String
testFileBase name = baseDir <> "/" <> name

readFileNormalized :: String -> IO B8.ByteString
readFileNormalized f = B8.filter (/= '\r') <$> B8.readFile f

amz_credentialScope :: CredentialScope
amz_credentialScope = either error id
    $ fromText "20110909/us-east-1/host/aws4_request"

amz_credentialsIO :: IO SignatureV4Credentials
amz_credentialsIO = newCredentials "AKIDEXAMPLE" "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"

amz_credentials :: SignatureV4Credentials
amz_credentials = unsafePerformIO $ amz_credentialsIO
{-# NOINLINE amz_credentials #-}

-- | Note that the input is an invalid date. The day 2011-09-09 was a
-- a Friday in the Gregorian calendar.
--
amz_testDateStr :: IsString a => a
amz_testDateStr = "Mon, 09 Sep 2011 23:36:00 GMT"

amz_testDate :: UTCTime
amz_testDate = fromMaybe (error "failed to parse test date")
    $ parseHttpDate amz_testDateStr

-- | Test Parameters
--
data TestRequest = TestRequest
    { testRequestMethod :: !HTTP.Method
    , testRequestPath :: !UriPath
    , testRequestQuery :: !UriQuery
    , testRequestHeaders :: !HTTP.RequestHeaders
    , testRequestPayload :: !B.ByteString
    }
    deriving (Show, Eq)

parseRequest :: forall m . (Monad m, P.CharParsing m) => m TestRequest
parseRequest = uncurry <$> (TestRequest <$> word)
        <*> parseTestUri
        <*> many parseTestHeader
        <*> (fromString <$> ((line :: m String) *> P.manyTill P.anyChar P.eof))
        P.<?> "TestRequest"

parseTestUri :: forall m . P.CharParsing m => m (UriPath, UriQuery)
parseTestUri = fmap HTTP.queryToQueryText . HTTP.decodePath . B8.pack
    <$> word <* (line :: m String)

parseTestHeader :: P.CharParsing m => m HTTP.Header
parseTestHeader = (,)
    <$> (fromString <$> P.manyTill P.anyChar (P.char ':'))
    <*> line

word :: (P.CharParsing m, IsString a) => m a
word = fromString <$> P.manyTill P.anyChar (some P.space) P.<?> "word"

line :: (P.CharParsing m, IsString a) => m a
line = fromString
    <$> P.manyTill P.anyChar (P.newline *> P.notFollowedBy (P.char ' '))
    P.<?> "line"

-- ** Test Methods

testCanonicalRequest
    :: String
    -> TestRequest
    -> EitherT String IO CanonicalRequest
testCanonicalRequest name r = do
    creq_ <- liftIO $ readFileNormalized f
    if creq == creq_
    then return result
    else left $ "test " <> name <> " failed to compute canonical request: "
        <> "\n  expected:" <> show creq_
        <> "\n  computed:" <> show creq
  where
    f = testFileBase name <> ".creq"
    result@(CanonicalRequest creq) = canonicalRequest
        (testRequestMethod r)
        (testRequestPath r)
        (testRequestQuery r)
        (testRequestHeaders r)
        (testRequestPayload r)

testStringToSign
    :: String
    -> TestRequest
    -> CanonicalRequest
    -> EitherT String IO StringToSign
testStringToSign name _req creq = do
    sts_ <- liftIO $ readFileNormalized f
    if sts == sts_
    then return result
    else left $ "test " <> name <> " failed compute string to sgin: "
        <> "\n  expected:" <> show sts_
        <> "\n  computed:" <> show sts
  where
    f = testFileBase name <> ".sts"
    result@(StringToSign sts)  = stringToSign
        amz_testDate
        amz_credentialScope
        creq

testSignature :: String -> TestRequest -> StringToSign -> EitherT String IO Signature
testSignature name _req str = do
    sig_ <- liftIO $ getSignature <$> readFileNormalized f
    if sig == sig_
    then return result
    else left $ "test " <> name <> " failed to compute signature: "
        <> "\n  expected:" <> show sig_
        <> "\n  computed:" <> show sig
  where
    f = testFileBase name <> ".authz"
    key = signingKey amz_credentials amz_credentialScope
    result@(Signature sig) = requestSignature key str
    getSignature = snd . B8.spanEnd (/= '=')

testAuthorization :: String -> TestRequest -> Signature -> EitherT String IO B8.ByteString
testAuthorization name req sig = do
    authz_ <- liftIO $ readFileNormalized f
    authz <- result
    if authz == authz_
    then return authz
    else left $ "test " <> name <> " failed to compute authorization info: "
        <> "\n  expected:" <> show authz_
        <> "\n  computed:" <> show authz
  where
    f = testFileBase name <> ".authz"
    result = failWith "authorization header is missing"
        . lookup "authorization"
        . authorizationInfoHeader
        $ authorizationInfo
            amz_credentials
            amz_credentialScope
            (signedHeaders $ testRequestHeaders req)
            amz_testDate
            sig

-- | Run a single Test
--
testMain :: String -> IO Bool
testMain name = do
    testRequest <- readFileNormalized reqFile >>= \x -> case A8.parseOnly parseRequest x of
        Left e -> error $ "failed to parse test request file " <> reqFile <> ": " <> e
        Right r -> return r
    eitherT (\e -> putStrLn e >> return False) (const $ return True) $ do
        creq <- testCanonicalRequest name testRequest
        sts <- testStringToSign name testRequest creq
        sig <- testSignature name testRequest sts
        _authz <- testAuthorization name testRequest sig
        return True
  where
    reqFile = testFileBase name <> ".req"

-- | Run all Tests
--
allTests :: IO Bool
allTests = do
    testFiles <- filter isNotBlackListed
        . filter (L.isSuffixOf ".req")
        . concat . filter checkGroup . group
        <$> getDirectoryContents baseDir
    let sigtests = map (\x -> L.take (L.length x - 4) x) testFiles
    results <- forM sigtests $ \n ->
        testMain n `catch` \(e :: IOError) -> do
            putStrLn $ "test " <> n <> " failed with: " <> show e
            return False
    return $ and results
  where
    -- the Amazon AWS SignatureV4 test-suite seems incomplete.
    -- In particular the files with the results for
    -- /get-header-value-multiline.req/ seem to be missing.
    testExtensions = ["req", "creq", "sreq", "authz", "sts"]
    checkGroup g = L.intersect testExtensions (map fext g) == testExtensions
    group = L.groupBy ((==) `on` fbase)
    fbase = initSafe . L.dropWhileEnd (/= '.')
    fext = takeEndWhile (/= '.')
    takeEndWhile f = reverse . takeWhile f  . reverse

    -- the test *post-vanilla-query-nonunreserved* of the
    -- Amazon AWS SignatureV4 testsuite uses an inconsistent request
    -- format that can not be represented with the URI query
    -- type of the *http-types* package. Also the given result
    -- itself seems suspicious. We skip this test

    testBlackList =
        [ "post-vanilla-query-nonunreserved"
        ]
    testName = takeEndWhile (/= '/') . fbase
    isNotBlackListed = not . flip elem testBlackList . testName