nuthatch/test/Spec.hs

731 lines
25 KiB
Haskell
Raw Normal View History

module Main (main) where
import Control.Monad.IO.Class (liftIO)
import qualified Data.Aeson as A
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.Either (isLeft)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
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
, status400
, status404
, status405
)
import Network.Wai (Request (..))
import Network.Wai.Test
( SRequest (..)
, SResponse (..)
, defaultRequest
, runSession
, setPath
, srequest
)
import Nuthatch.Api
import Nuthatch.App
( authenticationHandler
, loginResponse
, utf8Boundary
)
import Nuthatch.Config (AuthMode (..), Config (..))
import Nuthatch.Paths
( buildObjectKey
, encodeUserId
, listCacheAncestorPrefixes
)
import Nuthatch.Protocol
( MutationRecord (..)
, ObjectObservation (..)
, desiredMatches
, growthBytes
, priorMatches
, quotaAllows
, recoveryEligible
, sameFingerprint
, terminalExpiryEligible
)
import Nuthatch.Types
import Nuthatch.Session (mintSession)
import Nuthatch.Ui (browsePage, browserScript, loginPage)
import Servant
import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler)
import Servant.Types.SourceT (source)
import Test.Tasty (TestTree, defaultMain, testGroup)
import Test.Tasty.HUnit ((@?=), assertBool, testCase)
import Test.Tasty.Hedgehog (testProperty)
main :: IO ()
main = defaultMain tests
tests :: TestTree
tests = testGroup "nuthatch-unit"
[ pathTests
, identityTests
, protocolTests
, apiTests
]
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
, 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 "only stale active records are recovery eligible" propRecoveryTiming
]
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 InvalidUsername
deleteError = A.encode (DeleteMutationRejected "locked")
prefixError = A.encode (DeletePrefixMutationRejected "partial")
assertBool "login errors carry the login-specific code"
("invalid_username" `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 login emits both cookies"
testAuthenticationBoundary
, testCase "retained browser forms are wired to the typed API bridge"
testBrowserBridge
, 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
"/?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 "/?root=PERSONAL" [] ""
simpleStatus invalidRoot @?= status400
invalidPath <- apiRequest calls methodGet "/?prefix=docs%2F..%2Fprivate" [] ""
simpleStatus invalidPath @?= status400
readIORef calls >>= (@?= 0)
testMalformedUtf8 :: IO ()
testMalformedUtf8 = do
calls <- newIORef 0
response <- apiRequest calls methodGet "/?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
"/upload?root=personal&path=docs%2Ffile.txt&operation_id=bad%20id"
headers
"body"
simpleStatus response @?= status400
invalidSize <- apiRequest calls methodPost
"/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 "/?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
"/upload?root=personal&path=docs%2Ffile.txt&operation_id=upload-1"
headers
"body"
simpleStatus upload @?= status200
download <- apiRequest calls methodGet
"/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 methodPost
"/login"
[ ("Content-Type", "application/json")
, ("Accept", "application/json")
]
"{\"username\":\"alice\"}"
simpleStatus login @?= status200
assertBool "login response must carry a user identity"
("user_id" `BS.isInfixOf` BL.toStrict (simpleBody login))
formLogin <- apiRequest calls methodPost
"/login"
[("Content-Type", "application/x-www-form-urlencoded")]
"username=bob"
simpleStatus formLogin @?= status200
assertBool "browser login response navigates into the UI"
("continue" `BS.isInfixOf` BL.toStrict (simpleBody formLogin))
invalidLogin <- apiRequest calls methodPost
"/login"
[("Content-Type", "application/json")]
"{\"username\":\"two words\"}"
simpleStatus invalidLogin @?= status400
deletion <- apiRequest calls methodPost
"/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))
malformed <- apiRequest calls methodPost
"/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 (HtmlPage "login")
:<|> counted (BrowserScript "script")
:<|> (\_ -> counted
(addHeader "session=cookie"
(addHeader "csrf=cookie"
(addHeader (CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
(LoginView
(HtmlPage "continue")
(LoginResponse (UserId "user")))))))
:<|> (\_ _ -> counted
(addHeader "session=expired"
(addHeader "csrf=expired" NoContent)))
:<|> (\_ _ _ _ _ -> counted
(BrowseView
(HtmlPage "browse")
BrowseResponse
{ browseEntries = []
, browseContinuation = Nothing
, browsePartial = False
}))
:<|> (\_ _ _ -> counted
(addHeader "attachment; filename=\"file.txt\""
(addHeader "nosniff" (source ["body"]))))
:<|> (\_ _ _ operation _ _ _ _ -> counted UploadResponse
{ uploadResponseOperationId = operation
, uploadResponseResult = MutationSucceeded
})
:<|> (\_ request -> counted DeleteResponse
{ deleteResponseOperationId = deleteOperationId request
, deleteResponseResult = MutationSucceeded
})
:<|> (\_ _ -> counted (DeletePrefixResponse 1))
where
counted :: a -> Handler a
counted value = liftIO (modifyIORef' calls (+ 1)) >> pure value
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 (UserId "user") token csrf)
cookieHeaders =
[ value
| (name, value) <- responseHeaders
, name == "Set-Cookie"
]
length cookieHeaders @?= 2
assertBool "login sets the session cookie"
(any ("nuthatch_session=" `BS.isPrefixOf`) cookieHeaders)
assertBool "login sets the CSRF cookie"
(any ("nuthatch_csrf=" `BS.isPrefixOf`) cookieHeaders)
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
"/?root=personal"
[("Cookie", cookie)]
""
simpleStatus authenticated @?= status200
readIORef calls >>= (@?= 1)
writeIORef calls 0
malformed <- apiRequestWith app methodGet
"/?root=personal"
[("Cookie", "nuthatch_session=" <> BS.singleton 255
<> "; nuthatch_csrf=" <> TE.encodeUtf8 (unCsrfToken csrf))]
""
simpleStatus malformed @?= status400
readIORef calls >>= (@?= 0)
missingCsrf <- apiRequestWith app methodGet
"/?root=personal"
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token))]
""
simpleStatus missingCsrf @?= status200
readIORef calls >>= (@?= 1)
testBrowserBridge :: IO ()
testBrowserBridge = do
let csrf = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
user = SessionUser
{ suIssuer = "issuer"
, suSubject = "subject"
, suUserId = UserId "user"
, suDisplay = Just "User"
}
entry = ListEntry
{ leName = "folder"
, leIsPrefix = True
, leSize = Nothing
, leRelativePath = "folder"
}
browse = browsePage
user
Personal
""
csrf
"upload-1"
["delete-1"]
[entry]
Nothing
""
assertBool "login page loads the external bridge"
("/nuthatch.js" `T.isInfixOf` loginPage "")
mapM_
(\marker -> assertBool ("browse page includes " <> T.unpack marker)
(marker `T.isInfixOf` browse))
[ "data-nuthatch=\"upload\""
, "data-nuthatch=\"delete\""
, "data-nuthatch=\"logout\""
]
mapM_
(\endpoint -> assertBool ("bridge calls " <> T.unpack endpoint)
(endpoint `T.isInfixOf` browserScript))
[ "/upload"
, "/delete-prefix"
, "/logout"
]
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
, cfgPublicBaseUrl = "https://nuthatch.example"
, cfgJwtSecret = "unit-test-secret-material"
, cfgSessionHours = 1
, cfgCookieName = "nuthatch_session"
, cfgCsrfCookieName = "nuthatch_csrf"
, cfgPgConn = ""
, cfgRedisHost = ""
, cfgRedisPort = 6379
, cfgRedisDb = 0
, cfgS3Endpoint = "https://s3.example"
, cfgS3Region = "test"
, cfgS3Bucket = "test"
, cfgS3AccessKey = "test"
, cfgS3SecretKey = "test"
, cfgS3PathStyle = True
, cfgPersonalQuotaBytes = 1024
, cfgSharedQuotaBytes = 1024
, cfgMaxObjectBytes = 1024
, cfgThrottleBytesPerSec = 1024
, cfgThrottleBurstBytes = 1024
, cfgListCacheTtlSec = 30
, cfgSearchMaxKeys = 100
, cfgRecoveryGraceSec = 30
, cfgAuthMode = AuthDev
}
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)
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)
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
}