1273 lines
45 KiB
Haskell
1273 lines
45 KiB
Haskell
module Main (main) where
|
||
|
||
import Control.Concurrent (threadDelay)
|
||
import Control.Concurrent.MVar
|
||
( newEmptyMVar
|
||
, putMVar
|
||
, takeMVar
|
||
)
|
||
import Control.Exception
|
||
( AsyncException (ThreadKilled)
|
||
, SomeException
|
||
, finally
|
||
, throwIO
|
||
, try
|
||
)
|
||
import Control.Monad (forM_)
|
||
import Control.Monad.IO.Class (liftIO)
|
||
import Control.Retry (simulatePolicy)
|
||
import Control.Lens ((&), (?~))
|
||
import Crypto.JOSE (runJOSE)
|
||
import Crypto.JOSE.Compact (encodeCompact)
|
||
import Crypto.JOSE.JWA.JWS (Alg (RS256, RS384))
|
||
import Crypto.JOSE.JWK
|
||
( JWK
|
||
, JWKSet (..)
|
||
, KeyMaterialGenParam (RSAGenParam)
|
||
, genJWK
|
||
)
|
||
import Crypto.JOSE.JWS (newJWSHeaderProtected)
|
||
import Crypto.Hash (Digest, SHA256, hash)
|
||
import Crypto.JWT
|
||
( Audience (..)
|
||
, ClaimsSet
|
||
, HasClaimsSet (..)
|
||
, JWTError
|
||
, NumericDate (..)
|
||
, emptyClaimsSet
|
||
, signJWT
|
||
)
|
||
import qualified Data.Aeson as A
|
||
import qualified Data.Aeson.KeyMap as KM
|
||
import qualified Data.ByteArray as BA
|
||
import qualified Data.ByteString as BS
|
||
import qualified Data.ByteString.Base16 as B16
|
||
import qualified Data.ByteString.Lazy as BL
|
||
import Data.Either (isLeft, isRight)
|
||
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
|
||
import Data.String (fromString)
|
||
import Data.Text (Text)
|
||
import qualified Data.Text as T
|
||
import qualified Data.Text.Encoding as TE
|
||
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)
|
||
import GHC.Clock (getMonotonicTimeNSec)
|
||
import Hedgehog
|
||
( Gen
|
||
, Property
|
||
, assert
|
||
, forAll
|
||
, property
|
||
, (===)
|
||
)
|
||
import qualified Hedgehog.Gen as Gen
|
||
import qualified Hedgehog.Range as Range
|
||
import Network.HTTP.Types
|
||
( HeaderName
|
||
, methodGet
|
||
, methodPost
|
||
, status200
|
||
, status303
|
||
, status400
|
||
, status404
|
||
, status405
|
||
)
|
||
import Network.Wai (Request (..), responseLBS)
|
||
import Network.Wai.Test
|
||
( SRequest (..)
|
||
, SResponse (..)
|
||
, defaultRequest
|
||
, runSession
|
||
, setPath
|
||
, srequest
|
||
)
|
||
import Nuthatch.Api
|
||
import Nuthatch.Audit (auditValue, objectKeyFields)
|
||
import Nuthatch.Browse (collectKeysBoundedWith)
|
||
import Nuthatch.Config
|
||
( Config (..)
|
||
, parseRedisUri
|
||
, secret
|
||
, validateAbsoluteUri
|
||
)
|
||
import Nuthatch.Http.Auth
|
||
( authenticationHandler
|
||
, loginResponse
|
||
)
|
||
import Nuthatch.Http.Server (utf8Boundary)
|
||
import Nuthatch.Oidc (OidcIdentity (..), verifyIdToken)
|
||
import Nuthatch.Paths
|
||
( buildObjectKey
|
||
, encodeUserId
|
||
, listCacheAncestorPrefixes
|
||
)
|
||
import Nuthatch.Protocol
|
||
( ExistingDisposition (..)
|
||
, MutationRecord (..)
|
||
, ObjectObservation (..)
|
||
, desiredMatches
|
||
, existingDisposition
|
||
, growthBytes
|
||
, priorMatches
|
||
, quotaAllows
|
||
, recoveryEligible
|
||
, sameFingerprint
|
||
, terminalExpiryEligible
|
||
, terminalExpired
|
||
)
|
||
import Nuthatch.Recovery
|
||
( recoveryClaimEligible
|
||
, recoveryRetryPolicy
|
||
, trySynchronous
|
||
, withLinkedWorker
|
||
)
|
||
import Nuthatch.S3 (ListPage (..))
|
||
import Nuthatch.Runtime (retryStartup)
|
||
import Nuthatch.Types
|
||
import Nuthatch.Session (mintSession)
|
||
import Nuthatch.Streaming
|
||
( UploadStreamError (..)
|
||
, consumeUploadParts
|
||
, maximumMultipartBytes
|
||
, multipartPartBytes
|
||
)
|
||
import Servant
|
||
import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler)
|
||
import Servant.Types.SourceT
|
||
( SourceT
|
||
, StepT (..)
|
||
, fromStepT
|
||
, source
|
||
)
|
||
import qualified System.Timeout as Timeout
|
||
import Test.Tasty (TestTree, defaultMain, testGroup)
|
||
import Test.Tasty.HUnit ((@?=), assertBool, assertFailure, testCase)
|
||
import Test.Tasty.Hedgehog (testProperty)
|
||
|
||
main :: IO ()
|
||
main = defaultMain tests
|
||
|
||
tests :: TestTree
|
||
tests = testGroup "nuthatch-unit"
|
||
[ pathTests
|
||
, identityTests
|
||
, protocolTests
|
||
, recoveryTests
|
||
, streamingTests
|
||
, productBehaviorTests
|
||
, runtimeTests
|
||
, oidcTests
|
||
, apiTests
|
||
]
|
||
|
||
runtimeTests :: TestTree
|
||
runtimeTests = testGroup "runtime"
|
||
[ testCase "URI parsing accepts IPv6 and rejects credentials" $ do
|
||
parseRedisUri "redis://[2001:db8::1]:6380/4"
|
||
>>= (@?= ("2001:db8::1", 6380, 4))
|
||
redisCredential <- try
|
||
(parseRedisUri "redis://user:[email protected]/0")
|
||
:: IO (Either SomeException (String, Int, Integer))
|
||
assertBool "credential-bearing Redis URI rejected" (isLeft redisCredential)
|
||
endpointCredential <- try
|
||
(validateAbsoluteUri
|
||
"endpoint"
|
||
["https"]
|
||
"https://user:[email protected]")
|
||
:: IO (Either SomeException Text)
|
||
assertBool "credential-bearing endpoint rejected"
|
||
(isLeft endpointCredential)
|
||
, testCase "startup retry is bounded and succeeds within its budget" $ do
|
||
calls <- newIORef (0 :: Int)
|
||
result <- retryStartup 3 1 "synthetic" $ do
|
||
modifyIORef' calls (+ 1)
|
||
count <- readIORef calls
|
||
if count < 3 then fail "not ready" else pure count
|
||
result @?= 3
|
||
readIORef calls >>= (@?= 3)
|
||
, testCase "startup retry exhausts its configured budget" $ do
|
||
calls <- newIORef (0 :: Int)
|
||
result <- try $
|
||
retryStartup 2 1 "synthetic" $ do
|
||
modifyIORef' calls (+ 1)
|
||
fail "not ready"
|
||
:: IO (Either SomeException ())
|
||
assertBool "retry exhaustion fails startup" (isLeft result)
|
||
readIORef calls >>= (@?= 2)
|
||
, testCase "audit records retain required fields and drop prohibited fields" $ do
|
||
now <- getCurrentTime
|
||
let record = auditValue now "put" "rejected" (Just "user")
|
||
[ ("root", A.String "shared")
|
||
, ("authorization", A.String "Bearer prohibited")
|
||
, ("token", A.String "prohibited")
|
||
]
|
||
case record of
|
||
A.Object fields -> do
|
||
assertBool "required timestamp present" (KM.member "ts" fields)
|
||
KM.lookup "action" fields @?= Just (A.String "put")
|
||
KM.lookup "result" fields @?= Just (A.String "rejected")
|
||
KM.lookup "user_id" fields @?= Just (A.String "user")
|
||
KM.lookup "root" fields @?= Just (A.String "shared")
|
||
assertBool "authorization omitted" (not (KM.member "authorization" fields))
|
||
assertBool "token omitted" (not (KM.member "token" fields))
|
||
_ -> assertFailure "audit record was not an object"
|
||
, testCase "audit object keys follow the configured privacy policy" $ do
|
||
objectKeyFields False "private/name" @?= []
|
||
objectKeyFields True "private/name"
|
||
@?= [("object_key", A.String "private/name")]
|
||
]
|
||
|
||
productBehaviorTests :: TestTree
|
||
productBehaviorTests = testGroup "product behavior"
|
||
[ testCase "search deadline cancels an in-flight listing" $ do
|
||
started <- getMonotonicTimeNSec
|
||
result <- collectKeysBoundedWith
|
||
getMonotonicTimeNSec
|
||
(\_ _ -> do
|
||
threadDelay 100000
|
||
pure (Right (ListPage [] [] Nothing False)))
|
||
100
|
||
(started + 5000000)
|
||
finished <- getMonotonicTimeNSec
|
||
result @?= Right ([], True)
|
||
assertBool "listing returns inside the configured deadline margin"
|
||
(finished - started < 50000000)
|
||
]
|
||
|
||
streamingTests :: TestTree
|
||
streamingTests = testGroup "streaming"
|
||
[ testCase "upload bodies are inspected and emitted in bounded parts" $ do
|
||
inspected <- newIORef []
|
||
emitted <- newIORef []
|
||
consumeUploadParts
|
||
6
|
||
(strictSha256 "abcdef")
|
||
2
|
||
4
|
||
(\piece -> modifyIORef' inspected (<> [piece]))
|
||
(source ["abcdef"])
|
||
(\part -> modifyIORef' emitted (<> [part]))
|
||
readIORef inspected >>= (@?= ["ab", "cd", "ef"])
|
||
readIORef emitted >>= (@?= ["abcd", "ef"])
|
||
, testCase "multipart buffering has a fixed part-count bound" $
|
||
maximumMultipartBytes @?= toInteger multipartPartBytes * 10000
|
||
, testCase "upload rejects early end, surplus bytes, and identity mismatch" $ do
|
||
short <- uploadFailure 4 (strictSha256 "body") (source ["bod"])
|
||
short @?= UploadStreamSizeMismatch
|
||
long <- uploadFailure 4 (strictSha256 "body") (source ["body!"])
|
||
long @?= UploadStreamSizeMismatch
|
||
identity <- uploadFailure 4 (strictSha256 "else") (source ["body"])
|
||
identity @?= UploadStreamIdentityMismatch
|
||
, testCase "upload preserves source errors" $ do
|
||
failure <- uploadFailure
|
||
4
|
||
(strictSha256 "body")
|
||
(fromStepT (Error "request failed"))
|
||
failure @?= UploadStreamInvalid "request failed"
|
||
]
|
||
|
||
uploadFailure
|
||
:: Integer
|
||
-> ContentSha256
|
||
-> SourceT IO BS.ByteString
|
||
-> IO UploadStreamError
|
||
uploadFailure size identity body = do
|
||
result <- try $
|
||
consumeUploadParts
|
||
size identity 2 4 (const (pure ())) body (const (pure ()))
|
||
case result of
|
||
Left failure -> pure failure
|
||
Right _ -> fail "expected upload stream rejection"
|
||
|
||
strictSha256 :: BS.ByteString -> ContentSha256
|
||
strictSha256 bytes =
|
||
ContentSha256 . TE.decodeUtf8 . B16.encode . BA.convert $
|
||
(hash bytes :: Digest SHA256)
|
||
|
||
pathTests :: TestTree
|
||
pathTests = testGroup "canonical paths"
|
||
[ testProperty "valid paths parse and render without rewriting" propPathRoundTrip
|
||
, testProperty "invalid paths are rejected" propInvalidPaths
|
||
, testProperty "constructed object keys remain under their root" propRootConfinement
|
||
, testCase "nested cache ancestors include every containing listing" $
|
||
listCacheAncestorPrefixes (RelativePath "docs/a/b.txt")
|
||
@?= ["", "docs", "docs/a"]
|
||
, testCase "root files invalidate the root listing" $
|
||
listCacheAncestorPrefixes (RelativePath "file.txt") @?= [""]
|
||
]
|
||
|
||
identityTests :: TestTree
|
||
identityTests = testGroup "identities"
|
||
[ testProperty "root labels parse back to the same root" propRootLabels
|
||
, testProperty "mutation kind labels parse back to the same kind" propKindLabels
|
||
, testCase "issuer and subject identity encoding remains stable" $
|
||
encodeUserId "https://issuer.example" "subject-α"
|
||
@?= UserId "e0240ab33521a1882fb647a79f7fd3c74c9b6348b29bdb14e572204517882859"
|
||
, testProperty "issuer and subject inputs are separated" propUserIdentitySeparation
|
||
]
|
||
|
||
protocolTests :: TestTree
|
||
protocolTests = testGroup "mutation protocol decisions"
|
||
[ testProperty "request fingerprints distinguish every bound field" propFingerprintDistinction
|
||
, testCase "removed terminal identity starts a new mutation epoch" $ do
|
||
let rec = sampleMutation
|
||
Put
|
||
(ObjectKey "users/u/file")
|
||
4
|
||
(Just (ContentSha256 "content"))
|
||
case existingDisposition Nothing Put (mrObjectKey rec) 4 (mrContentSha256 rec) of
|
||
NewMutationEpoch -> pure ()
|
||
other -> assertFailure ("expected new epoch, got " <> show other)
|
||
case existingDisposition (Just rec) Put (mrObjectKey rec) 4 (mrContentSha256 rec) of
|
||
ExistingReplay MutationPending _ -> pure ()
|
||
other -> assertFailure ("expected replay, got " <> show other)
|
||
case existingDisposition (Just rec) Put (mrObjectKey rec) 5 (mrContentSha256 rec) of
|
||
ExistingConflict -> pure ()
|
||
other -> assertFailure ("expected conflict, got " <> show other)
|
||
, testProperty "quota growth and admission preserve the limit" propQuotaInvariant
|
||
, testProperty "object observations match the independent classifier" propObservations
|
||
, testCase "unavailable observations match no terminal state" $ do
|
||
let rec = sampleMutation Put (ObjectKey "shared/file") 10
|
||
(Just (ContentSha256 "desired"))
|
||
observation = ObjectUnavailable "timeout"
|
||
desiredMatches rec observation @?= False
|
||
priorMatches rec observation @?= False
|
||
, testCase "ambiguous same-size content matches no terminal state" $ do
|
||
let rec = (sampleMutation Put (ObjectKey "shared/file") 10
|
||
(Just (ContentSha256 "desired")))
|
||
{ mrPriorSize = 10
|
||
, mrPriorSha256 = Just (ContentSha256 "prior")
|
||
}
|
||
observation = ObjectPresent 10 (Just (ContentSha256 "other"))
|
||
desiredMatches rec observation @?= False
|
||
priorMatches rec observation @?= False
|
||
, testProperty "only settled terminal records are expiry eligible" propExpiryEligibility
|
||
, testProperty "terminal identities expire only after the window" propTerminalExpiry
|
||
, testProperty "only stale active records are recovery eligible" propRecoveryTiming
|
||
]
|
||
|
||
recoveryTests :: TestTree
|
||
recoveryTests = testGroup "recovery"
|
||
[ testProperty "worker lease and settlement independently gate recovery"
|
||
propRecoveryClaim
|
||
, testCase "asynchronous cancellation is not converted to failure" $ do
|
||
result <- try (trySynchronous (throwIO ThreadKilled))
|
||
:: IO (Either AsyncException (Either SomeException ()))
|
||
case result of
|
||
Left ThreadKilled -> pure ()
|
||
other -> assertFailure ("asynchronous exception was swallowed: " <> show other)
|
||
, testCase "linked worker failure interrupts the foreground" $ do
|
||
result <- Timeout.timeout 1000000 $
|
||
try (withLinkedWorker
|
||
(threadDelay 10000 >> fail "worker failed")
|
||
(threadDelay 5000000))
|
||
:: IO (Maybe (Either SomeException ()))
|
||
case result of
|
||
Just (Left _) -> pure ()
|
||
other -> assertFailure ("linked worker did not fail promptly: " <> show other)
|
||
, testCase "structured worker scope cancels and joins the worker" $ do
|
||
started <- newEmptyMVar
|
||
stopped <- newEmptyMVar
|
||
withLinkedWorker
|
||
(putMVar started () >>
|
||
(threadDelay 5000000 `finally` putMVar stopped ()))
|
||
(takeMVar started)
|
||
takeMVar stopped
|
||
, testCase "recovery retry policy has bounded exponential decisions" $ do
|
||
schedule <- simulatePolicy 8 recoveryRetryPolicy
|
||
let decisions = map snd schedule
|
||
retries = length [() | Just _ <- decisions]
|
||
assertBool "retry policy must retry" (retries > 0)
|
||
assertBool "retry policy must be bounded" (retries <= 5)
|
||
assertBool "retry policy must exhaust" (Nothing `elem` decisions)
|
||
]
|
||
|
||
oidcTests :: TestTree
|
||
oidcTests = testGroup "OIDC validation"
|
||
[ testCase "accepts an exact RS256 ID token" testValidIdToken
|
||
, testCase "accepts an exact configured audience set" testConfiguredAudiences
|
||
, testCase "rejects invalid nonce, algorithm, issuer, signature, and audience"
|
||
testIdTokenCryptographicRejections
|
||
, testCase "rejects malformed, missing-time, future, expired, and overlong tokens"
|
||
testIdTokenTimeAndSerializationRejections
|
||
]
|
||
|
||
data TestIdTokenClaims = TestIdTokenClaims
|
||
{ testIdTokenRegistered :: ClaimsSet
|
||
, testIdTokenNonce :: Text
|
||
, testIdTokenAuthorizedParty :: Maybe Text
|
||
, testIdTokenName :: Maybe Text
|
||
}
|
||
deriving stock (Eq, Show)
|
||
|
||
instance HasClaimsSet TestIdTokenClaims where
|
||
claimsSet update claims =
|
||
fmap
|
||
(\registered -> claims { testIdTokenRegistered = registered })
|
||
(update (testIdTokenRegistered claims))
|
||
|
||
instance A.FromJSON TestIdTokenClaims where
|
||
parseJSON value = A.withObject "test ID token" (\object ->
|
||
TestIdTokenClaims
|
||
<$> A.parseJSON value
|
||
<*> object A..: "nonce"
|
||
<*> object A..:? "azp"
|
||
<*> object A..:? "name") value
|
||
|
||
instance A.ToJSON TestIdTokenClaims where
|
||
toJSON claims =
|
||
case A.toJSON (testIdTokenRegistered claims) of
|
||
A.Object object ->
|
||
let withName =
|
||
maybe
|
||
object
|
||
(\name -> KM.insert "name" (A.String name) object)
|
||
(testIdTokenName claims)
|
||
withAuthorizedParty =
|
||
maybe
|
||
withName
|
||
(\authorizedParty ->
|
||
KM.insert "azp" (A.String authorizedParty) withName)
|
||
(testIdTokenAuthorizedParty claims)
|
||
in A.Object $
|
||
KM.insert
|
||
"nonce"
|
||
(A.String (testIdTokenNonce claims))
|
||
withAuthorizedParty
|
||
value -> value
|
||
|
||
testValidIdToken :: IO ()
|
||
testValidIdToken = do
|
||
key <- genJWK (RSAGenParam 256)
|
||
now <- getCurrentTime
|
||
compact <- signTestIdToken key RS256
|
||
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||
result <- verifyIdToken
|
||
testConfig
|
||
(JWKSet [key])
|
||
now
|
||
"nonce"
|
||
compact
|
||
result @?= Right OidcIdentity
|
||
{ identityIssuer = "https://issuer.example"
|
||
, identitySubject = "upstream-subject"
|
||
, identityDisplay = Just "Example User"
|
||
}
|
||
|
||
testConfiguredAudiences :: IO ()
|
||
testConfiguredAudiences = do
|
||
key <- genJWK (RSAGenParam 256)
|
||
now <- getCurrentTime
|
||
compact <- signTestIdToken key RS256
|
||
((testIdClaims now "https://issuer.example" ["project", "nuthatch"] "nonce")
|
||
{ testIdTokenAuthorizedParty = Just "nuthatch" })
|
||
missingAuthorizedParty <- signTestIdToken key RS256
|
||
(testIdClaims now "https://issuer.example" ["project", "nuthatch"] "nonce")
|
||
result <- verifyIdToken
|
||
testConfig { cfgOidcAdditionalAudiences = ["project"] }
|
||
(JWKSet [key])
|
||
now
|
||
"nonce"
|
||
compact
|
||
rejected <- verifyIdToken
|
||
testConfig { cfgOidcAdditionalAudiences = ["project"] }
|
||
(JWKSet [key])
|
||
now
|
||
"nonce"
|
||
missingAuthorizedParty
|
||
assertBool "the configured audience set is accepted" (isRight result)
|
||
assertBool "a multi-audience token without azp is rejected" (isLeft rejected)
|
||
|
||
testIdTokenCryptographicRejections :: IO ()
|
||
testIdTokenCryptographicRejections = do
|
||
key <- genJWK (RSAGenParam 256)
|
||
otherKey <- genJWK (RSAGenParam 256)
|
||
now <- getCurrentTime
|
||
valid <- signTestIdToken key RS256
|
||
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||
wrongAlgorithm <- signTestIdToken key RS384
|
||
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||
wrongIssuer <- signTestIdToken key RS256
|
||
(testIdClaims now "https://other.example" ["nuthatch"] "nonce")
|
||
wrongSignature <- signTestIdToken otherKey RS256
|
||
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||
wrongAudience <- signTestIdToken key RS256
|
||
(testIdClaims now "https://issuer.example" ["other"] "nonce")
|
||
multipleAudience <- signTestIdToken key RS256
|
||
(testIdClaims now "https://issuer.example" ["nuthatch", "other"] "nonce")
|
||
let rejected =
|
||
[ verifyIdToken testConfig (JWKSet [key]) now "other-nonce" valid
|
||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongAlgorithm
|
||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongIssuer
|
||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongSignature
|
||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongAudience
|
||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" multipleAudience
|
||
]
|
||
results <- sequence rejected
|
||
assertBool "every invalid cryptographic or binding case is rejected"
|
||
(all isLeft results)
|
||
|
||
testIdTokenTimeAndSerializationRejections :: IO ()
|
||
testIdTokenTimeAndSerializationRejections = do
|
||
key <- genJWK (RSAGenParam 256)
|
||
now <- getCurrentTime
|
||
missingIssuedAt <- signTestIdToken key RS256
|
||
((testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||
{ testIdTokenRegistered =
|
||
emptyClaimsSet
|
||
& claimIss ?~ "https://issuer.example"
|
||
& claimSub ?~ "upstream-subject"
|
||
& claimAud ?~ Audience ["nuthatch"]
|
||
& claimExp ?~ NumericDate (addUTCTime 300 now)
|
||
})
|
||
future <- signTestIdToken key RS256
|
||
(testIdClaimsAt
|
||
(addUTCTime 60 now)
|
||
(addUTCTime 300 now)
|
||
"https://issuer.example"
|
||
["nuthatch"]
|
||
"nonce")
|
||
expired <- signTestIdToken key RS256
|
||
(testIdClaimsAt
|
||
(addUTCTime (-600) now)
|
||
(addUTCTime (-300) now)
|
||
"https://issuer.example"
|
||
["nuthatch"]
|
||
"nonce")
|
||
overlong <- signTestIdToken key RS256
|
||
(testIdClaimsAt
|
||
now
|
||
(addUTCTime 7200 now)
|
||
"https://issuer.example"
|
||
["nuthatch"]
|
||
"nonce")
|
||
results <- sequence
|
||
[ verifyIdToken testConfig (JWKSet [key]) now "nonce" "not.compact"
|
||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" missingIssuedAt
|
||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" future
|
||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" expired
|
||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" overlong
|
||
]
|
||
assertBool "every invalid serialization or time case is rejected"
|
||
(all isLeft results)
|
||
|
||
testIdClaims
|
||
:: UTCTime
|
||
-> Text
|
||
-> [Text]
|
||
-> Text
|
||
-> TestIdTokenClaims
|
||
testIdClaims now issuer audiences nonce =
|
||
testIdClaimsAt
|
||
now
|
||
(addUTCTime 300 now)
|
||
issuer
|
||
audiences
|
||
nonce
|
||
|
||
testIdClaimsAt
|
||
:: UTCTime
|
||
-> UTCTime
|
||
-> Text
|
||
-> [Text]
|
||
-> Text
|
||
-> TestIdTokenClaims
|
||
testIdClaimsAt issuedAt expiresAt issuer audiences nonce =
|
||
TestIdTokenClaims
|
||
{ testIdTokenRegistered =
|
||
emptyClaimsSet
|
||
& claimIss ?~ fromString (T.unpack issuer)
|
||
& claimSub ?~ "upstream-subject"
|
||
& claimAud ?~ Audience (map (fromString . T.unpack) audiences)
|
||
& claimIat ?~ NumericDate issuedAt
|
||
& claimExp ?~ NumericDate expiresAt
|
||
, testIdTokenNonce = nonce
|
||
, testIdTokenAuthorizedParty = Nothing
|
||
, testIdTokenName = Just "Example User"
|
||
}
|
||
|
||
signTestIdToken
|
||
:: JWK
|
||
-> Alg
|
||
-> TestIdTokenClaims
|
||
-> IO Text
|
||
signTestIdToken key algorithm claims = do
|
||
result <- runJOSE $
|
||
encodeCompact <$> signJWT key (newJWSHeaderProtected algorithm) claims
|
||
case result of
|
||
Left (err :: JWTError) -> fail (show err)
|
||
Right compact -> pure (TE.decodeUtf8 (BL.toStrict compact))
|
||
|
||
apiTests :: TestTree
|
||
apiTests = testGroup "typed HTTP boundary"
|
||
[ testCase "Servant selects routes and methods" testRouteSelection
|
||
, testCase "valid query values reach a typed handler" testValidDecoding
|
||
, testCase "invalid roots and paths fail before handler execution" testInvalidDomains
|
||
, testCase "malformed UTF-8 fails before handler execution" testMalformedUtf8
|
||
, testCase "upload headers and operation ids decode before streaming handler execution"
|
||
testUploadDecoding
|
||
, testCase "upload and download bodies use Servant streaming endpoints"
|
||
testStreamingEndpoints
|
||
, testCase "operation-specific JSON requests and responses remain distinct"
|
||
testOperationRepresentations
|
||
, testCase "operation-specific errors have closed distinct representations" $ do
|
||
let loginError = A.encode OidcCallbackRejected
|
||
deleteError = A.encode (DeleteMutationRejected "locked")
|
||
prefixError = A.encode (DeletePrefixMutationRejected "partial")
|
||
assertBool "login errors carry the login-specific code"
|
||
("oidc_callback_rejected" `BS.isInfixOf` BL.toStrict loginError)
|
||
assertBool "delete errors carry the mutation-specific code"
|
||
("mutation_rejected" `BS.isInfixOf` BL.toStrict deleteError)
|
||
assertBool "operation errors must not share one representation"
|
||
(loginError /= deleteError && deleteError /= prefixError)
|
||
assertBool "prefix errors carry their operation-specific code"
|
||
("prefix_mutation_rejected" `BS.isInfixOf` BL.toStrict prefixError)
|
||
, testCase "production authentication validates cookies and callback emits secure cookies"
|
||
testAuthenticationBoundary
|
||
, testCase "only canonical product routes are exposed"
|
||
testCanonicalProductRoutes
|
||
, testCase "links derive from the type-level API" $
|
||
show (linkURI (safeLink api (Proxy :: Proxy HealthEndpoint)))
|
||
@?= "healthz"
|
||
]
|
||
|
||
testRouteSelection :: IO ()
|
||
testRouteSelection = do
|
||
calls <- newIORef 0
|
||
response <- apiRequest calls methodGet "/healthz" [] ""
|
||
simpleStatus response @?= status200
|
||
readIORef calls >>= (@?= 1)
|
||
writeIORef calls 0
|
||
wrongMethod <- apiRequest calls methodPost "/healthz" [] ""
|
||
simpleStatus wrongMethod @?= status405
|
||
readIORef calls >>= (@?= 0)
|
||
missing <- apiRequest calls methodGet "/missing" [] ""
|
||
simpleStatus missing @?= status404
|
||
readIORef calls >>= (@?= 0)
|
||
|
||
testValidDecoding :: IO ()
|
||
testValidDecoding = do
|
||
calls <- newIORef 0
|
||
response <- apiRequest calls methodGet
|
||
"/api/browse?root=personal&prefix=docs%2Freports&q=quarterly&continuation=next"
|
||
[]
|
||
""
|
||
simpleStatus response @?= status200
|
||
readIORef calls >>= (@?= 1)
|
||
|
||
testInvalidDomains :: IO ()
|
||
testInvalidDomains = do
|
||
calls <- newIORef 0
|
||
invalidRoot <- apiRequest calls methodGet "/api/browse?root=PERSONAL" [] ""
|
||
simpleStatus invalidRoot @?= status400
|
||
invalidPath <- apiRequest calls methodGet
|
||
"/api/browse?prefix=docs%2F..%2Fprivate" [] ""
|
||
simpleStatus invalidPath @?= status400
|
||
readIORef calls >>= (@?= 0)
|
||
|
||
testMalformedUtf8 :: IO ()
|
||
testMalformedUtf8 = do
|
||
calls <- newIORef 0
|
||
response <- apiRequest calls methodGet "/api/browse?q=%FF" [] ""
|
||
simpleStatus response @?= status400
|
||
readIORef calls >>= (@?= 0)
|
||
|
||
testUploadDecoding :: IO ()
|
||
testUploadDecoding = do
|
||
calls <- newIORef 0
|
||
let csrf = BS.replicate 43 97
|
||
let headers =
|
||
[ ("Content-Type", "application/octet-stream")
|
||
, ("X-Declared-Size", "4")
|
||
, ("X-Content-SHA256", BS.replicate 64 97)
|
||
, ("X-CSRF-Token", csrf)
|
||
]
|
||
response <- apiRequest calls methodPost
|
||
"/api/upload?root=personal&path=docs%2Ffile.txt&operation_id=bad%20id"
|
||
headers
|
||
"body"
|
||
simpleStatus response @?= status400
|
||
invalidSize <- apiRequest calls methodPost
|
||
"/api/upload?root=personal&path=docs%2Ffile.txt&operation_id=upload-1"
|
||
(replaceHeader "X-Declared-Size" "zero" headers)
|
||
"body"
|
||
simpleStatus invalidSize @?= status400
|
||
invalidPagination <- apiRequest calls methodGet
|
||
"/api/browse?continuation=" [] ""
|
||
simpleStatus invalidPagination @?= status400
|
||
readIORef calls >>= (@?= 0)
|
||
|
||
testStreamingEndpoints :: IO ()
|
||
testStreamingEndpoints = do
|
||
calls <- newIORef 0
|
||
let csrf = BS.replicate 43 97
|
||
let headers =
|
||
[ ("Content-Type", "application/octet-stream")
|
||
, ("X-Declared-Size", "4")
|
||
, ("X-Content-SHA256", BS.replicate 64 97)
|
||
, ("X-CSRF-Token", csrf)
|
||
]
|
||
upload <- apiRequest calls methodPost
|
||
"/api/upload?root=personal&path=docs%2Ffile.txt&operation_id=upload-1"
|
||
headers
|
||
"body"
|
||
simpleStatus upload @?= status200
|
||
assertBool "upload advertises the idempotency window"
|
||
("\"idempotency_window_seconds\":3600"
|
||
`BS.isInfixOf` BL.toStrict (simpleBody upload))
|
||
download <- apiRequest calls methodGet
|
||
"/api/download?root=shared&path=docs%2Ffile.txt"
|
||
[]
|
||
""
|
||
simpleStatus download @?= status200
|
||
simpleBody download @?= "body"
|
||
lookup "Content-Disposition" (simpleHeaders download)
|
||
@?= Just "attachment; filename=\"file.txt\""
|
||
lookup "X-Content-Type-Options" (simpleHeaders download)
|
||
@?= Just "nosniff"
|
||
readIORef calls >>= (@?= 2)
|
||
|
||
testOperationRepresentations :: IO ()
|
||
testOperationRepresentations = do
|
||
calls <- newIORef 0
|
||
login <- apiRequest calls methodGet "/login" [] ""
|
||
simpleStatus login @?= status303
|
||
lookup "Location" (simpleHeaders login) @?= Just "https://issuer.example/authorize"
|
||
callback <- apiRequest calls methodGet
|
||
"/oidc/callback?code=code&state=state"
|
||
[("Cookie", "nuthatch_oidc=transaction")]
|
||
""
|
||
simpleStatus callback @?= status303
|
||
wrongLoginMethod <- apiRequest calls methodPost "/login" [] ""
|
||
simpleStatus wrongLoginMethod @?= status405
|
||
deletion <- apiRequest calls methodPost
|
||
"/api/delete"
|
||
[("Content-Type", "application/json")]
|
||
"{\"root\":\"shared\",\"path\":\"docs/file.txt\",\"operation_id\":\"delete-1\",\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
|
||
simpleStatus deletion @?= status200
|
||
assertBool "delete response must carry a mutation result"
|
||
("succeeded" `BS.isInfixOf` BL.toStrict (simpleBody deletion))
|
||
assertBool "delete advertises the idempotency window"
|
||
("\"idempotency_window_seconds\":3600"
|
||
`BS.isInfixOf` BL.toStrict (simpleBody deletion))
|
||
malformed <- apiRequest calls methodPost
|
||
"/api/delete"
|
||
[("Content-Type", "application/json")]
|
||
"{\"root\":\"shared\",\"path\":\"../escape\"}"
|
||
simpleStatus malformed @?= status400
|
||
readIORef calls >>= (@?= 3)
|
||
|
||
replaceHeader
|
||
:: HeaderName
|
||
-> BS.ByteString
|
||
-> [(HeaderName, BS.ByteString)]
|
||
-> [(HeaderName, BS.ByteString)]
|
||
replaceHeader name value =
|
||
map $ \(headerName, headerValue) ->
|
||
if headerName == name
|
||
then (headerName, value)
|
||
else (headerName, headerValue)
|
||
|
||
apiRequest
|
||
:: IORef Int
|
||
-> BS.ByteString
|
||
-> BS.ByteString
|
||
-> [(HeaderName, BS.ByteString)]
|
||
-> BL.ByteString
|
||
-> IO SResponse
|
||
apiRequest calls method path headers body =
|
||
apiRequestWith (typedTestApplication calls) method path headers body
|
||
|
||
apiRequestWith
|
||
:: Application
|
||
-> BS.ByteString
|
||
-> BS.ByteString
|
||
-> [(HeaderName, BS.ByteString)]
|
||
-> BL.ByteString
|
||
-> IO SResponse
|
||
apiRequestWith app method path headers body =
|
||
runSession
|
||
(srequest
|
||
(SRequest
|
||
(setPath defaultRequest
|
||
{ requestMethod = method
|
||
, requestHeaders = headers
|
||
}
|
||
path)
|
||
body))
|
||
app
|
||
|
||
typedTestApplication :: IORef Int -> Application
|
||
typedTestApplication calls =
|
||
utf8Boundary $
|
||
serveWithContext api (auth :. EmptyContext) (typedTestServer calls)
|
||
where
|
||
auth :: AuthHandler Request SessionContext
|
||
auth = mkAuthHandler $ \_ -> pure SessionContext
|
||
{ sessionUser = SessionUser
|
||
{ suIssuer = "issuer"
|
||
, suSubject = "subject"
|
||
, suUserId = UserId "user"
|
||
, suDisplay = Nothing
|
||
}
|
||
, sessionCsrf = Just
|
||
(CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||
}
|
||
|
||
typedTestServer :: IORef Int -> Server Api
|
||
typedTestServer calls =
|
||
counted (HealthResponse True)
|
||
:<|> counted (ReadyResponse True)
|
||
:<|> (\_ -> counted (addHeader "default-src 'none'" (HtmlPage "app")))
|
||
:<|> (\_ -> Tagged $ \_ sendResponse ->
|
||
sendResponse (responseLBS status404 [] ""))
|
||
:<|> counted
|
||
(addHeader "transaction=cookie"
|
||
(addHeader "https://issuer.example/authorize" NoContent))
|
||
:<|> (\_ _ _ -> counted
|
||
(addHeader "session=cookie"
|
||
(addHeader "csrf=cookie"
|
||
(addHeader "transaction=expired"
|
||
(addHeader "/app" NoContent)))))
|
||
:<|> (\_ _ -> counted
|
||
(addHeader "session=expired"
|
||
(addHeader "csrf=expired"
|
||
(addHeader "transaction=expired" NoContent))))
|
||
:<|>
|
||
( (\session -> counted SessionResponse
|
||
{ sessionResponseUserId = suUserId (sessionUser session)
|
||
, sessionResponseDisplay = Nothing
|
||
, sessionResponseCsrf =
|
||
CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||
})
|
||
:<|> (\_ _ _ _ _ -> counted emptyBrowse)
|
||
:<|> (\_ _ _ _ -> counted downloadResponse)
|
||
:<|> (\_ _ _ operation _ _ _ _ -> counted UploadResponse
|
||
{ uploadResponseOperationId = operation
|
||
, uploadResponseResult = MutationSucceeded
|
||
, uploadResponseIdempotencyWindowSeconds = 3600
|
||
})
|
||
:<|> (\_ request -> counted DeleteResponse
|
||
{ deleteResponseOperationId = deleteOperationId request
|
||
, deleteResponseResult = MutationSucceeded
|
||
, deleteResponseIdempotencyWindowSeconds = 3600
|
||
})
|
||
:<|> (\_ _ _ _ -> counted DeletePlanResponse
|
||
{ deletePlanPaths = []
|
||
, deletePlanContinuation = Nothing
|
||
})
|
||
:<|> (\_ _ -> counted (DeleteBatchResponse []))
|
||
)
|
||
where
|
||
counted :: a -> Handler a
|
||
counted value = liftIO (modifyIORef' calls (+ 1)) >> pure value
|
||
emptyBrowse =
|
||
BrowseResponse
|
||
{ browseEntries = []
|
||
, browseContinuation = Nothing
|
||
, browsePartial = False
|
||
}
|
||
downloadResponse =
|
||
addHeader "attachment; filename=\"file.txt\"" $
|
||
addHeader "nosniff" $
|
||
addHeader "sandbox" $
|
||
addHeader 4 (source ["body"])
|
||
|
||
testAuthenticationBoundary :: IO ()
|
||
testAuthenticationBoundary = do
|
||
tokenResult <- mintSession testConfig "issuer" "subject" Nothing
|
||
token <- either (fail . T.unpack) pure tokenResult
|
||
let csrf = CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||
responseHeaders = getHeaders
|
||
(loginResponse testConfig token csrf)
|
||
cookieHeaders =
|
||
[ value
|
||
| (name, value) <- responseHeaders
|
||
, name == "Set-Cookie"
|
||
]
|
||
lookup "Location" responseHeaders @?= Just "/app"
|
||
length cookieHeaders @?= 3
|
||
assertBool "login sets the session cookie"
|
||
(any ("nuthatch_session=" `BS.isPrefixOf`) cookieHeaders)
|
||
assertBool "login sets the CSRF cookie"
|
||
(any ("nuthatch_csrf=" `BS.isPrefixOf`) cookieHeaders)
|
||
assertBool "cookies are secure and same-site"
|
||
(all ("Secure" `BS.isInfixOf`) cookieHeaders
|
||
&& all ("SameSite=Lax" `BS.isInfixOf`) cookieHeaders)
|
||
assertBool "cookies are host-only"
|
||
(all (not . ("Domain=" `BS.isInfixOf`)) cookieHeaders)
|
||
assertBool "session and CSRF cookies use the root path"
|
||
(length (filter ("Path=/;" `BS.isInfixOf`) cookieHeaders) == 2)
|
||
assertBool "the transaction cookie uses the callback path"
|
||
(any ("Path=/oidc/callback;" `BS.isInfixOf`) cookieHeaders)
|
||
assertBool "session and transaction cookies are HTTP-only"
|
||
(length (filter ("HttpOnly" `BS.isInfixOf`) cookieHeaders) == 2)
|
||
|
||
calls <- newIORef 0
|
||
let app = productionAuthTestApplication testConfig calls
|
||
cookie =
|
||
"nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token)
|
||
<> "; nuthatch_csrf=" <> TE.encodeUtf8 (unCsrfToken csrf)
|
||
authenticated <- apiRequestWith app methodGet
|
||
"/api/browse?root=personal"
|
||
[("Cookie", cookie)]
|
||
""
|
||
simpleStatus authenticated @?= status200
|
||
readIORef calls >>= (@?= 1)
|
||
|
||
writeIORef calls 0
|
||
malformed <- apiRequestWith app methodGet
|
||
"/api/browse?root=personal"
|
||
[("Cookie", "nuthatch_session=" <> BS.singleton 255
|
||
<> "; nuthatch_csrf=" <> TE.encodeUtf8 (unCsrfToken csrf))]
|
||
""
|
||
simpleStatus malformed @?= status400
|
||
readIORef calls >>= (@?= 0)
|
||
|
||
malformedGrammar <- apiRequestWith app methodGet
|
||
"/api/browse?root=personal"
|
||
[("Cookie", "nuthatch_session")]
|
||
""
|
||
simpleStatus malformedGrammar @?= status400
|
||
readIORef calls >>= (@?= 0)
|
||
|
||
let tamperedToken =
|
||
case T.unsnoc (unSessionToken token) of
|
||
Just (prefix, final)
|
||
| final == 'a' -> prefix <> "b"
|
||
| otherwise -> prefix <> "a"
|
||
Nothing -> "invalid"
|
||
tampered <- apiRequestWith app methodGet
|
||
"/api/browse?root=personal"
|
||
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 tamperedToken)]
|
||
""
|
||
simpleStatus tampered @?= status303
|
||
readIORef calls >>= (@?= 0)
|
||
|
||
missingCsrf <- apiRequestWith app methodGet
|
||
"/api/browse?root=personal"
|
||
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token))]
|
||
""
|
||
simpleStatus missingCsrf @?= status200
|
||
readIORef calls >>= (@?= 1)
|
||
|
||
testCanonicalProductRoutes :: IO ()
|
||
testCanonicalProductRoutes = do
|
||
calls <- newIORef 0
|
||
forM_
|
||
[ (methodGet, "/")
|
||
, (methodGet, "/nuthatch.js")
|
||
, (methodGet, "/download?root=personal&path=file")
|
||
, (methodPost, "/upload")
|
||
, (methodPost, "/delete")
|
||
, (methodPost, "/delete-prefix")
|
||
] $ \(method, path) -> do
|
||
response <- apiRequest calls method path [] ""
|
||
simpleStatus response @?= status404
|
||
readIORef calls >>= (@?= 0)
|
||
|
||
productionAuthTestApplication :: Config -> IORef Int -> Application
|
||
productionAuthTestApplication cfg calls =
|
||
utf8Boundary $
|
||
serveWithContext
|
||
api
|
||
(authenticationHandler cfg :. EmptyContext)
|
||
(typedTestServer calls)
|
||
|
||
testConfig :: Config
|
||
testConfig = Config
|
||
{ cfgListenHost = "127.0.0.1"
|
||
, cfgListenPort = 8080
|
||
, cfgTlsCertPath = "/tls/tls.crt"
|
||
, cfgTlsKeyPath = "/tls/tls.key"
|
||
, cfgPublicBaseUrl = "https://nuthatch.example"
|
||
, cfgJwtSecret = secret "unit-test-session-secret-material-32"
|
||
, cfgSessionHours = 1
|
||
, cfgCookieName = "nuthatch_session"
|
||
, cfgCsrfCookieName = "nuthatch_csrf"
|
||
, cfgOidcTransactionCookieName = "nuthatch_oidc"
|
||
, cfgOidcIssuer = "https://issuer.example"
|
||
, cfgOidcClientId = "nuthatch"
|
||
, cfgOidcAdditionalAudiences = []
|
||
, cfgOidcClientSecret = secret "unit-test-client-secret"
|
||
, cfgOidcRedirectUri = "https://nuthatch.example/oidc/callback"
|
||
, cfgOidcTransactionMinutes = 5
|
||
, cfgOidcMaxIdTokenSeconds = 3600
|
||
, cfgPgConn = secret ""
|
||
, cfgRedisHost = ""
|
||
, cfgRedisPort = 6379
|
||
, cfgRedisDb = 0
|
||
, cfgS3Endpoint = "https://s3.example"
|
||
, cfgS3Region = "test"
|
||
, cfgS3Bucket = "test"
|
||
, cfgS3AccessKey = secret "test"
|
||
, cfgS3SecretKey = secret "test"
|
||
, cfgS3PathStyle = True
|
||
, cfgPersonalQuotaBytes = 1024
|
||
, cfgSharedQuotaBytes = 1024
|
||
, cfgMaxObjectBytes = 1024
|
||
, cfgThrottleBytesPerSec = 1024
|
||
, cfgThrottleBurstBytes = 1024
|
||
, cfgListCacheTtlSec = 30
|
||
, cfgBrowsePageSize = 100
|
||
, cfgSearchMaxKeys = 100
|
||
, cfgSearchTimeoutMs = 1000
|
||
, cfgDeletePageSize = 100
|
||
, cfgRecoveryGraceSec = 30
|
||
, cfgIdempotencyWindowSec = 3600
|
||
, cfgObjectRequestTimeoutSec = 300
|
||
, cfgStartupAttempts = 3
|
||
, cfgStartupDelayMs = 10
|
||
, cfgAuditObjectKeys = False
|
||
}
|
||
|
||
newtype ValidPath = ValidPath { getValidPath :: Text }
|
||
deriving stock (Show)
|
||
|
||
newtype InvalidPath = InvalidPath { getInvalidPath :: Text }
|
||
deriving stock (Show)
|
||
|
||
genValidPath :: Gen ValidPath
|
||
genValidPath = do
|
||
segments <- Gen.list (Range.linear 1 6) genSegment
|
||
pure (ValidPath (T.intercalate "/" segments))
|
||
where
|
||
genSegment =
|
||
Gen.filter (`notElem` [".", ".."]) $
|
||
Gen.text (Range.linear 1 16) $
|
||
Gen.choice [Gen.alphaNum, Gen.element ("_-. " :: String)]
|
||
|
||
genInvalidPath :: Gen InvalidPath
|
||
genInvalidPath = do
|
||
ValidPath valid <- genValidPath
|
||
InvalidPath <$> Gen.element
|
||
[ ""
|
||
, "/" <> valid
|
||
, valid <> "/"
|
||
, valid <> "//tail"
|
||
, valid <> "/../tail"
|
||
, valid <> "/./tail"
|
||
, valid <> "/back\\slash"
|
||
, valid <> "/control\NUL"
|
||
, "e\x0301"
|
||
]
|
||
|
||
propPathRoundTrip :: Property
|
||
propPathRoundTrip = property $ do
|
||
ValidPath raw <- forAll genValidPath
|
||
validateRelativePath raw === Right (RelativePath raw)
|
||
|
||
propInvalidPaths :: Property
|
||
propInvalidPaths = property $ do
|
||
InvalidPath raw <- forAll genInvalidPath
|
||
assert (isLeft (validateRelativePath raw))
|
||
|
||
propRootConfinement :: Property
|
||
propRootConfinement = property $ do
|
||
ValidPath raw <- forAll genValidPath
|
||
let path = RelativePath raw
|
||
uid = encodeUserId "https://issuer.example" "subject"
|
||
personalPrefix = "users/" <> unUserId uid <> "/"
|
||
personalKey = unObjectKey (buildObjectKey Personal uid path)
|
||
sharedKey = unObjectKey (buildObjectKey Shared uid path)
|
||
assert (personalPrefix `T.isPrefixOf` personalKey)
|
||
assert ("shared/" `T.isPrefixOf` sharedKey)
|
||
assert (not ("shared/" `T.isPrefixOf` personalKey))
|
||
assert (not ("users/" `T.isPrefixOf` sharedKey))
|
||
|
||
propRootLabels :: Property
|
||
propRootLabels = property $ do
|
||
root <- forAll (Gen.element [Personal, Shared])
|
||
parseRoot (rootLabel root) === Just root
|
||
|
||
propKindLabels :: Property
|
||
propKindLabels = property $ do
|
||
kind <- forAll (Gen.element [Put, Delete])
|
||
parseKind (kindLabel kind) === Just kind
|
||
|
||
propUserIdentitySeparation :: Property
|
||
propUserIdentitySeparation = property $ do
|
||
issuer <- forAll genIdentityPart
|
||
subject <- forAll genIdentityPart
|
||
otherIssuer <- forAll (Gen.filter (/= issuer) genIdentityPart)
|
||
other <- forAll (Gen.filter (/= subject) genIdentityPart)
|
||
assert (encodeUserId issuer subject /= encodeUserId otherIssuer subject)
|
||
assert (encodeUserId issuer subject /= encodeUserId issuer other)
|
||
where
|
||
genIdentityPart = Gen.text (Range.linear 1 32) Gen.alphaNum
|
||
|
||
propFingerprintDistinction :: Property
|
||
propFingerprintDistinction = property $ do
|
||
ValidPath raw <- forAll genValidPath
|
||
size <- forAll (Gen.integral (Range.linear 1 1000000))
|
||
let uid = encodeUserId "issuer" "subject"
|
||
key = buildObjectKey Personal uid (RelativePath raw)
|
||
otherRootKey = buildObjectKey Shared uid (RelativePath raw)
|
||
otherPathKey = buildObjectKey Personal uid (RelativePath ("other/" <> raw))
|
||
sha = Just (ContentSha256 "content-a")
|
||
rec = sampleMutation Put key size sha
|
||
assert (sameFingerprint rec Put key size sha)
|
||
assert (not (sameFingerprint rec Delete key size sha))
|
||
assert (not (sameFingerprint rec Put otherRootKey size sha))
|
||
assert (not (sameFingerprint rec Put otherPathKey size sha))
|
||
assert (not (sameFingerprint rec Put key (size + 1) sha))
|
||
assert (not (sameFingerprint rec Put key size (Just (ContentSha256 "content-b"))))
|
||
|
||
propQuotaInvariant :: Property
|
||
propQuotaInvariant = property $ do
|
||
prior <- forAll nonNegativeBytes
|
||
desired <- forAll nonNegativeBytes
|
||
used <- forAll nonNegativeBytes
|
||
reserved <- forAll nonNegativeBytes
|
||
limit <- forAll nonNegativeBytes
|
||
let growth = growthBytes prior desired
|
||
modelGrowth = max 0 (desired - prior)
|
||
allowed = quotaAllows limit used reserved growth
|
||
growth === modelGrowth
|
||
assert (growth >= 0)
|
||
allowed === (used + reserved + modelGrowth <= limit)
|
||
if allowed
|
||
then assert (used + reserved + growth <= limit)
|
||
else assert (used + reserved + growth > limit)
|
||
where
|
||
nonNegativeBytes = Gen.integral (Range.linear 0 1000000000)
|
||
|
||
propObservations :: Property
|
||
propObservations = property $ do
|
||
kind <- forAll (Gen.element [Put, Delete])
|
||
desiredSize <- forAll (Gen.integral (Range.linear 1 1000000))
|
||
priorSize <- forAll (Gen.integral (Range.linear 0 1000000))
|
||
observation <- forAll genObservation
|
||
let desiredSha = Just (ContentSha256 "desired")
|
||
priorSha = if priorSize == 0 then Nothing else Just (ContentSha256 "prior")
|
||
rec = (sampleMutation kind (ObjectKey "users/u/file") desiredSize desiredSha)
|
||
{ mrPriorSize = priorSize
|
||
, mrPriorSha256 = priorSha
|
||
}
|
||
desiredMatches rec observation === modelDesired kind desiredSize desiredSha observation
|
||
priorMatches rec observation === modelPrior priorSize priorSha observation
|
||
|
||
propExpiryEligibility :: Property
|
||
propExpiryEligibility = property $ do
|
||
state <- forAll genMutationState
|
||
external <- forAll genExternalState
|
||
terminalExpiryEligible state external
|
||
=== (state `elem` [Succeeded, Failed, Rejected] && external == ExtIdle)
|
||
|
||
propTerminalExpiry :: Property
|
||
propTerminalExpiry = property $ do
|
||
state <- forAll genMutationState
|
||
external <- forAll genExternalState
|
||
elapsed <- forAll (Gen.integral (Range.linear 0 10000))
|
||
window <- forAll (Gen.integral (Range.linear 1 10000))
|
||
terminalExpired state external elapsed window
|
||
=== ( state `elem` [Succeeded, Failed, Rejected]
|
||
&& external == ExtIdle
|
||
&& elapsed > window
|
||
)
|
||
|
||
propRecoveryTiming :: Property
|
||
propRecoveryTiming = property $ do
|
||
state <- forAll genMutationState
|
||
elapsed <- forAll (Gen.integral (Range.linear 0 10000))
|
||
grace <- forAll (Gen.integral (Range.linear 0 10000))
|
||
recoveryEligible state elapsed grace
|
||
=== (state `elem` [Prepared, Applying] && elapsed > grace)
|
||
|
||
propRecoveryClaim :: Property
|
||
propRecoveryClaim = property $ do
|
||
state <- forAll genMutationState
|
||
external <- forAll genExternalState
|
||
elapsed <- forAll (Gen.integral (Range.linear 0 10000))
|
||
grace <- forAll (Gen.integral (Range.linear 0 10000))
|
||
leaseExpired <- forAll Gen.bool
|
||
deadlineExpired <- forAll Gen.bool
|
||
recoveryClaimEligible
|
||
state
|
||
external
|
||
elapsed
|
||
grace
|
||
leaseExpired
|
||
deadlineExpired
|
||
=== ( state `elem` [Prepared, Applying]
|
||
&& case external of
|
||
ExtIdle -> elapsed > grace
|
||
ExtSettled -> elapsed > grace
|
||
ExtPending -> leaseExpired && deadlineExpired
|
||
)
|
||
|
||
genObservation :: Gen ObjectObservation
|
||
genObservation = Gen.choice
|
||
[ pure ObjectAbsent
|
||
, pure (ObjectUnavailable "dependency unavailable")
|
||
, ObjectPresent
|
||
<$> Gen.integral (Range.linear 0 1000000)
|
||
<*> Gen.element
|
||
[ Nothing
|
||
, Just (ContentSha256 "desired")
|
||
, Just (ContentSha256 "prior")
|
||
, Just (ContentSha256 "other")
|
||
]
|
||
]
|
||
|
||
modelDesired
|
||
:: MutationKind
|
||
-> Integer
|
||
-> Maybe ContentSha256
|
||
-> ObjectObservation
|
||
-> Bool
|
||
modelDesired kind size sha observation =
|
||
case (kind, observation) of
|
||
(Delete, ObjectAbsent) -> True
|
||
(Put, ObjectPresent observedSize observedSha) ->
|
||
observedSize == size
|
||
&& case (sha, observedSha) of
|
||
(Just expected, Just actual) -> expected == actual
|
||
(Just _, Nothing) -> False
|
||
(Nothing, _) -> True
|
||
_ -> False
|
||
|
||
modelPrior
|
||
:: Integer
|
||
-> Maybe ContentSha256
|
||
-> ObjectObservation
|
||
-> Bool
|
||
modelPrior size sha observation =
|
||
case observation of
|
||
ObjectUnavailable _ -> False
|
||
ObjectAbsent -> size == 0 && sha == Nothing
|
||
ObjectPresent observedSize observedSha ->
|
||
observedSize == size
|
||
&& case (sha, observedSha) of
|
||
(Just expected, Just actual) -> expected == actual
|
||
(Nothing, Nothing) -> True
|
||
(Just _, Nothing) -> False
|
||
(Nothing, Just _) -> True
|
||
|
||
genMutationState :: Gen MutationState
|
||
genMutationState =
|
||
Gen.element [Prepared, Applying, Succeeded, Failed, Rejected]
|
||
|
||
genExternalState :: Gen ExternalState
|
||
genExternalState = Gen.element [ExtIdle, ExtPending, ExtSettled]
|
||
|
||
sampleMutation
|
||
:: MutationKind
|
||
-> ObjectKey
|
||
-> Integer
|
||
-> Maybe ContentSha256
|
||
-> MutationRecord
|
||
sampleMutation kind key size sha = MutationRecord
|
||
{ mrUserId = UserId "u"
|
||
, mrKind = kind
|
||
, mrOperationId = OperationId "op"
|
||
, mrPoolId = PoolId "personal:u"
|
||
, mrObjectKey = key
|
||
, mrRelativePath = RelativePath "file"
|
||
, mrDeclaredSize = size
|
||
, mrContentSha256 = sha
|
||
, mrPriorSize = 0
|
||
, mrPriorSha256 = Nothing
|
||
, mrGrowth = size
|
||
, mrState = Applying
|
||
, mrExternal = ExtSettled
|
||
}
|