module Main (main) where import Data.Aeson (Value (..), eitherDecodeStrict') import qualified Data.Aeson.Key as Key import qualified Data.Aeson.KeyMap as KeyMap import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as B8 import qualified Data.Text as Text import Nuthatch.Probe.Assert import Nuthatch.Probe.Curl import Nuthatch.Probe.Oidc import Nuthatch.Probe.ProductClient import System.Environment (getArgs) import System.IO.Temp (withSystemTempDirectory) main :: IO () main = withSystemTempDirectory "nuthatch-product-probe" $ \directory -> do context <- newProbeContext directory user <- requireEnv "NUTHATCH_OIDC_USER_A" session <- login context False user "product" getArgs >>= \case ["verify"] -> verifyProduct context session ["timeout"] -> verifyTimeout context session _ -> fail "expected verify or timeout mode" verifyTimeout :: ProbeContext -> Session -> IO () verifyTimeout context session = do response <- curl [ "--get" , "--cookie", sessionCookies session , "--data-urlencode", "root=personal" , "--data-urlencode", "q=timeout-probe" , probeBase context <> "/api/browse" ] assertEqual "timed search status" 200 (curlStatus response) assertContains "timed search bound" "\"partial\":true" (curlBody response) putStrLn "Timed search bound passed" verifyProduct :: ProbeContext -> Session -> IO () verifyProduct context session = do verifyApplicationAssets context session verifyPaginationAndSearch context session verifyCanonicalPaths context session verifySafeDownload context session verifyRecursiveDelete context session putStrLn "Product behavior passed" verifyApplicationAssets :: ProbeContext -> Session -> IO () verifyApplicationAssets context session = do sessionResponse <- curl [ "--cookie", sessionCookies session , probeBase context <> "/api/session" ] assertEqual "session endpoint status" 200 (curlStatus sessionResponse) assertContains "session user" "\"user_id\":" (curlBody sessionResponse) assertContains "session CSRF" ("\"csrf\":\"" <> sessionCsrf session <> "\"") (curlBody sessionResponse) application <- curl [ "--cookie", sessionCookies session , probeBase context <> "/app" ] assertEqual "application status" 200 (curlStatus application) csp <- maybe (fail "application omitted CSP") pure $ headerValue "content-security-policy" application mapM_ (\directive -> assertContains ("application CSP omitted " <> B8.unpack directive) directive csp) ["default-src 'none'", "script-src 'self'", "frame-ancestors 'none'"] asset <- between "src=\"/assets/" "\"" (B8.unpack (curlBody application)) javascript <- curl [ "--cookie", sessionCookies session , probeBase context <> "/assets/" <> asset ] assertEqual "asset status" 200 (curlStatus javascript) assertContains "asset content type" "javascript" $ maybe BS.empty id (headerValue "content-type" javascript) assertEqual "asset nosniff" (Just "nosniff") (headerValue "x-content-type-options" javascript) let visible = curlBody application <> curlBody javascript mapM_ (\value -> assert ("browser artifact exposed " <> value) $ not (B8.pack value `BS.isInfixOf` visible)) [ "NUTHATCH_S3" , "AWS_ACCESS_KEY" , "AWS_SECRET_ACCESS_KEY" , "client_secret" ] mapM_ (\marker -> assertContains ("asset omitted " <> marker) (B8.pack marker) (curlBody javascript)) [ "Drop a file here" , "Shared root" , "Retry same upload" , "Retry same delete" , "nuthatch-recursive-delete" , "URLSearchParams" ] unauthenticatedApp <- curl [probeBase context <> "/app"] assertEqual "unauthenticated application" 303 (curlStatus unauthenticatedApp) unauthenticatedAsset <- curl [probeBase context <> "/assets/" <> asset] assertEqual "unauthenticated asset" 303 (curlStatus unauthenticatedAsset) verifyPaginationAndSearch :: ProbeContext -> Session -> IO () verifyPaginationAndSearch context session = do uploadSucceeded context session "product-browse/Alpha.txt" "81000000-0000-4000-8000-000000000001" "alpha" replay <- upload context session "personal" "product-browse/Alpha.txt" "81000000-0000-4000-8000-000000000001" "alpha" requireSucceeded "product upload replay" replay uploadSucceeded context session "product-browse/beta.txt" "81000000-0000-4000-8000-000000000002" "beta" uploadSucceeded context session "product-browse/gamma.txt" "81000000-0000-4000-8000-000000000003" "gamma" firstPage <- curl [ "--get" , "--cookie", sessionCookies session , "--data-urlencode", "root=personal" , "--data-urlencode", "prefix=product-browse" , probeBase context <> "/api/browse" ] requireStatus "first browse page" 200 firstPage assertContains "first page Alpha" "\"path\":\"product-browse/Alpha.txt\"" (curlBody firstPage) assertContains "first page beta" "\"path\":\"product-browse/beta.txt\"" (curlBody firstPage) assert "first page exceeded configured size" $ not ("product-browse/gamma.txt" `BS.isInfixOf` curlBody firstPage) continuation <- stringField "continuation" (curlBody firstPage) secondPage <- curl [ "--get" , "--cookie", sessionCookies session , "--data-urlencode", "root=personal" , "--data-urlencode", "prefix=product-browse" , "--data-urlencode", "continuation=" <> continuation , probeBase context <> "/api/browse" ] assertContains "second browse page" "\"path\":\"product-browse/gamma.txt\"" (curlBody secondPage) mapM_ (\(path, operationId, body) -> uploadSucceeded context session path operationId body) [ ("000-Needle-A.txt", "81000000-0000-4000-8000-000000000004", "one") , ("001-needle-b.txt", "81000000-0000-4000-8000-000000000005", "two") , ("002-filler.txt", "81000000-0000-4000-8000-000000000006", "three") , ("003-needle-after-bound.txt", "81000000-0000-4000-8000-000000000007", "four") ] searched <- curl [ "--get" , "--cookie", sessionCookies session , "--data-urlencode", "root=personal" , "--data-urlencode", "q=NEEDLE" , probeBase context <> "/api/browse" ] mapM_ (\value -> assertContains "bounded case-insensitive search" value (curlBody searched)) [ "\"path\":\"000-Needle-A.txt\"" , "\"path\":\"001-needle-b.txt\"" , "\"partial\":true" ] assert "search crossed the configured scan bound" $ not ("003-needle-after-bound.txt" `BS.isInfixOf` curlBody searched) verifyCanonicalPaths :: ProbeContext -> Session -> IO () verifyCanonicalPaths context session = do mapM_ (\url -> curl ["--cookie", sessionCookies session, url] >>= requireStatus "non-canonical path" 400) [ probeBase context <> "/api/browse?root=Personal" , probeBase context <> "/api/browse?root=personal&prefix=a//b" , probeBase context <> "/api/download?root=personal&path=./file" ] decomposed <- curl [ "--cookie", sessionCookies session , probeBase context <> "/api/download?root=personal&path=cafe%CC%81.txt" ] requireStatus "non-NFC path" 400 decomposed uploadSucceeded context session "encoded%2Fname.txt" "81000000-0000-4000-8000-000000000008" "encoded" encoded <- curl [ "--cookie", sessionCookies session , probeBase context <> "/api/download?root=personal&path=encoded%252Fname.txt" ] assertEqual "encoded path body" "encoded" (curlBody encoded) uploadSucceeded context session "café.txt" "81000000-0000-4000-8000-000000000014" "unicode" unicode <- download context session "personal" "café.txt" assertEqual "Unicode path body" "unicode" (curlBody unicode) assertContains "Unicode filename" "filename*=UTF-8''caf%C3%A9.txt" (maybe BS.empty id (headerValue "content-disposition" unicode)) verifySafeDownload :: ProbeContext -> Session -> IO () verifySafeDownload context session = do let svg = "" uploadSucceeded context session "active.svg" "81000000-0000-4000-8000-000000000009" svg ranged <- curl [ "--cookie", sessionCookies session , "--header", "Range: bytes=0-1" , probeBase context <> "/api/download?root=personal&path=active.svg" ] requireStatus "unsupported range" 416 ranged assertContains "range error" "\"error\":\"range_not_supported\"" (curlBody ranged) downloaded <- download context session "personal" "active.svg" requireStatus "safe download" 200 downloaded let disposition = maybe BS.empty id (headerValue "content-disposition" downloaded) assertContains "download attachment" "attachment;" disposition assertContains "download filename" "filename*=UTF-8''active.svg" disposition assertEqual "download nosniff" (Just "nosniff") (headerValue "x-content-type-options" downloaded) assertContains "download sandbox" "sandbox" (maybe BS.empty id (headerValue "content-security-policy" downloaded)) assertEqual "download body" svg (curlBody downloaded) verifyRecursiveDelete :: ProbeContext -> Session -> IO () verifyRecursiveDelete context session = do mapM_ (\(path, operationId, body) -> uploadSucceeded context session path operationId body) [ ("delete-tree/a.txt", "81000000-0000-4000-8000-000000000010", "a") , ("delete-tree/b.txt", "81000000-0000-4000-8000-000000000011", "b") , ("delete-tree/c.txt", "81000000-0000-4000-8000-000000000012", "c") ] planned <- deletePlan context session assertContains "first delete plan" "\"paths\":[\"delete-tree/a.txt\",\"delete-tree/b.txt\"]" (curlBody planned) assert "first delete plan omitted continuation" $ not ("\"continuation\":null" `BS.isInfixOf` curlBody planned) let firstBatch = "{\"root\":\"personal\",\"csrf\":\"" <> B8.unpack (sessionCsrf session) <> "\",\"items\":[" <> "{\"path\":\"delete-tree/a.txt\",\"operation_id\":" <> "\"82000000-0000-4000-8000-000000000001\"}," <> "{\"path\":\"delete-tree/b.txt\",\"operation_id\":" <> "\"82000000-0000-4000-8000-000000000002\"}]}" first <- deleteBatch context session firstBatch requireBatchSucceeded "first delete batch" first replay <- deleteBatch context session firstBatch requireBatchSucceeded "delete batch replay" replay uploadSucceeded context session "delete-tree/d-concurrent.txt" "81000000-0000-4000-8000-000000000013" "d" restarted <- deletePlan context session assertContains "restarted delete plan" "\"paths\":[\"delete-tree/c.txt\",\"delete-tree/d-concurrent.txt\"]" (curlBody restarted) let secondBatch = "{\"root\":\"personal\",\"csrf\":\"" <> B8.unpack (sessionCsrf session) <> "\",\"items\":[" <> "{\"path\":\"delete-tree/c.txt\",\"operation_id\":" <> "\"82000000-0000-4000-8000-000000000003\"}," <> "{\"path\":\"delete-tree/d-concurrent.txt\",\"operation_id\":" <> "\"82000000-0000-4000-8000-000000000004\"}]}" second <- deleteBatch context session secondBatch requireBatchSucceeded "second delete batch" second empty <- deletePlan context session assertContains "empty delete plan" "\"paths\":[]" (curlBody empty) assertContains "empty delete continuation" "\"continuation\":null" (curlBody empty) deletePlan :: ProbeContext -> Session -> IO CurlResponse deletePlan context session = curl [ "--get" , "--cookie", sessionCookies session , "--data-urlencode", "root=personal" , "--data-urlencode", "path=delete-tree" , probeBase context <> "/api/delete-plan" ] deleteBatch :: ProbeContext -> Session -> String -> IO CurlResponse deleteBatch context session body = curl [ "--cookie", sessionCookies session , "--header", "Content-Type: application/json" , "--data", body , probeBase context <> "/api/delete-batch" ] requireBatchSucceeded :: String -> CurlResponse -> IO () requireBatchSucceeded label response = do requireStatus label 200 response assertEqual label 2 $ countOccurrences "\"result\":\"succeeded\"" (curlBody response) uploadSucceeded :: ProbeContext -> Session -> String -> String -> BS.ByteString -> IO () uploadSucceeded context session path operationId body = upload context session "personal" path operationId body >>= requireSucceeded ("upload " <> path) requireSucceeded :: String -> CurlResponse -> IO () requireSucceeded label response = do requireStatus label 200 response assertContains label "\"result\":\"succeeded\"" (curlBody response) requireStatus :: String -> Int -> CurlResponse -> IO () requireStatus label expected response = assertEqual (label <> " status") expected (curlStatus response) stringField :: String -> BS.ByteString -> IO String stringField name bytes = case eitherDecodeStrict' bytes of Right (Object object) -> case KeyMap.lookup (Key.fromString name) object of Just (String value) -> pure (Text.unpack value) _ -> fail ("JSON omitted string field " <> name) Right _ -> fail "expected JSON object" Left reason -> fail ("invalid JSON: " <> reason) between :: String -> String -> String -> IO String between start end value = case dropUntil start value of Nothing -> fail ("value omitted " <> start) Just afterStart -> case takeUntil end afterStart of Nothing -> fail ("value omitted " <> end) Just result -> pure result dropUntil :: String -> String -> Maybe String dropUntil needle value | needle `prefixOf` value = Just (drop (length needle) value) dropUntil _ [] = Nothing dropUntil needle (_ : rest) = dropUntil needle rest takeUntil :: String -> String -> Maybe String takeUntil needle = go [] where go prefix value | needle `prefixOf` value = Just (reverse prefix) go _ [] = Nothing go prefix (next : rest) = go (next : prefix) rest prefixOf :: String -> String -> Bool prefixOf prefix value = take (length prefix) value == prefix countOccurrences :: BS.ByteString -> BS.ByteString -> Int countOccurrences needle input | BS.null input = 0 | needle `BS.isPrefixOf` input = 1 + countOccurrences needle (BS.drop (BS.length needle) input) | otherwise = countOccurrences needle (BS.drop 1 input)