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.App ( authenticationHandler , collectKeysBoundedWith , loginResponse , utf8Boundary ) import Nuthatch.Config ( Config (..) , parseRedisUri , secret , validateAbsoluteUri ) 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:password@example.test/0") :: IO (Either SomeException (String, Int, Integer)) assertBool "credential-bearing Redis URI rejected" (isLeft redisCredential) endpointCredential <- try (validateAbsoluteUri "endpoint" ["https"] "https://user:password@example.test") :: 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 }