Type ordinary integration probe HTTP traffic

This commit is contained in:
ms2k 2026-07-20 23:06:01 +09:00
commit cc5c47f2ce

View file

@ -4,37 +4,35 @@ import Control.Concurrent (threadDelay)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as B8
import Nuthatch.Probe.Assert
import Nuthatch.Probe.Curl
import Nuthatch.Probe.Http
import Nuthatch.Probe.Oidc
import Nuthatch.Probe.ProductClient
import System.Environment (getArgs)
import System.IO (hFlush, stdout)
import System.IO.Temp (withSystemTempDirectory)
import Text.Read (readMaybe)
main :: IO ()
main =
withSystemTempDirectory "nuthatch-runtime-http-probe" $ \directory -> do
context <- newProbeContext directory
getArgs >>= \case
["health", expectedText] ->
maybe
(fail "invalid expected readiness status")
(verifyHealth context)
(readMaybe expectedText)
["baseline"] -> withSession context True "runtime-baseline" $
verifyBaseline context
["coherence"] -> verifyCoherence context
["redis-unavailable"] ->
withSession context False "runtime-redis" $
verifyRedisUnavailable context
["database-unavailable"] ->
withSession context False "runtime-database" $
verifyDatabaseUnavailable context
_ ->
fail
"expected health STATUS, baseline, coherence, redis-unavailable, \
\or database-unavailable"
main = do
context <- newProbeContext
getArgs >>= \case
["health", expectedText] ->
maybe
(fail "invalid expected readiness status")
(verifyHealth context)
(readMaybe expectedText)
["baseline"] -> withSession context True "runtime-baseline" $
verifyBaseline context
["coherence"] -> verifyCoherence context
["redis-unavailable"] ->
withSession context False "runtime-redis" $
verifyRedisUnavailable context
["database-unavailable"] ->
withSession context False "runtime-database" $
verifyDatabaseUnavailable context
_ ->
fail
"expected health STATUS, baseline, coherence, redis-unavailable, \
\or database-unavailable"
withSession
:: ProbeContext
@ -48,20 +46,20 @@ withSession context verifyBoundary suffix action = do
verifyHealth :: ProbeContext -> Int -> IO ()
verifyHealth context expectedReady = do
health <- curl [probeBase context <> "/healthz"]
assertEqual "runtime health status" 200 (curlStatus health)
assertContains "runtime health body" "\"ok\":true" (curlBody health)
ready <- curl [probeBase context <> "/readyz"]
assertEqual "runtime readiness status" expectedReady (curlStatus ready)
_ <- curl [probeBase context <> "/app"]
session <- curl [probeBase context <> "/api/session"]
assertEqual "runtime unauthenticated session" 303 (curlStatus session)
health <- get (probeClient context) (probeBase context <> "/healthz")
assertEqual "runtime health status" 200 (probeStatus health)
assertContains "runtime health body" "\"ok\":true" (probeBody health)
ready <- get (probeClient context) (probeBase context <> "/readyz")
assertEqual "runtime readiness status" expectedReady (probeStatus ready)
_ <- get (probeClient context) (probeBase context <> "/app")
session <- get (probeClient context) (probeBase context <> "/api/session")
assertEqual "runtime unauthenticated session" 303 (probeStatus session)
putStrLn "Runtime health semantics passed"
verifyBaseline :: ProbeContext -> Session -> IO ()
verifyBaseline context sessionA = do
browsed <- browse context sessionA ""
assertEqual "runtime baseline browse" 200 (curlStatus browsed)
assertEqual "runtime baseline browse" 200 (probeStatus browsed)
uploaded <- upload context sessionA "personal" "cross-user.txt"
"76000000-0000-4000-8000-000000000001" "private-a"
requireSucceeded "runtime baseline put" uploaded
@ -69,30 +67,34 @@ verifyBaseline context sessionA = do
sessionB <- login context False userB "runtime-baseline-b"
personalB <- browse context sessionB "?root=personal"
assert "runtime personal cache crossed users" $
not ("cross-user.txt" `BS.isInfixOf` curlBody personalB)
wrongLogout <- curl
[ "--cookie", sessionCookies sessionA
, "--header"
, "X-CSRF-Token: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
, "--request", "POST"
, probeBase context <> "/logout"
]
not ("cross-user.txt" `BS.isInfixOf` probeBody personalB)
wrongLogout <- postWith
(probeClient context)
defaultRequestOptions
{ optionCookies = Just (sessionCookies sessionA)
, optionHeaders =
[ ( "X-CSRF-Token"
, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
)
]
}
(probeBase context <> "/logout")
assert "runtime baseline accepted wrong logout CSRF" $
curlStatus wrongLogout >= 400
probeStatus wrongLogout >= 400
deleted <- deleteObject context sessionA "personal" "cross-user.txt"
"76000000-0000-4000-8000-000000000002"
requireSucceeded "runtime baseline delete" deleted
restored <- upload context sessionA "personal" "cross-user.txt"
"76000000-0000-4000-8000-000000000003" "private-a"
requireSucceeded "runtime unavailable fixture" restored
loggedOut <- curl
[ "--cookie", sessionCookies sessionA
, "--cookie-jar", sessionCookies sessionA
, "--header", "X-CSRF-Token: " <> B8.unpack (sessionCsrf sessionA)
, "--request", "POST"
, probeBase context <> "/logout"
]
assertEqual "runtime baseline logout" 200 (curlStatus loggedOut)
loggedOut <- postWith
(probeClient context)
defaultRequestOptions
{ optionCookies = Just (sessionCookies sessionA)
, optionHeaders = [("X-CSRF-Token", sessionCsrf sessionA)]
}
(probeBase context <> "/logout")
assertEqual "runtime baseline logout" 200 (probeStatus loggedOut)
putStrLn "Runtime audit baseline passed"
verifyCoherence :: ProbeContext -> IO ()
@ -113,33 +115,33 @@ verifyCoherence context = do
rootListing <- browse context sessionB "?root=shared"
assertContains "shared root cache invalidation"
"runtime"
(curlBody rootListing)
(probeBody rootListing)
prefixListing <- browse context sessionB "?root=shared&prefix=runtime"
assertContains "shared prefix cache invalidation"
"coherence.txt"
(curlBody prefixListing)
(probeBody prefixListing)
deleted <- deleteObject context sessionA "shared" "runtime/coherence.txt"
"74000000-0000-4000-8000-000000000002"
requireSucceeded "shared coherence delete" deleted
afterDelete <- browse context sessionB "?root=shared&prefix=runtime"
assert "shared deletion remained in another replica cache" $
not ("coherence.txt" `BS.isInfixOf` curlBody afterDelete)
not ("coherence.txt" `BS.isInfixOf` probeBody afterDelete)
threadDelay 2000000
browse context sessionB "?root=shared" >>= \response ->
assertEqual "cache TTL browse" 200 (curlStatus response)
assertEqual "cache TTL browse" 200 (probeStatus response)
putStrLn "Cross-user cache coherence and TTL passed"
verifyRedisUnavailable :: ProbeContext -> Session -> IO ()
verifyRedisUnavailable context session = do
listing <- browse context session "?root=personal"
assertEqual "Redis-unavailable browse" 200 (curlStatus listing)
assertEqual "Redis-unavailable browse" 200 (probeStatus listing)
assertContains "Redis-unavailable browse fixture"
"cross-user.txt"
(curlBody listing)
(probeBody listing)
searched <- browse context session "?root=personal&q=CROSS-USER"
assertContains "Redis-unavailable search"
"cross-user.txt"
(curlBody searched)
(probeBody searched)
unavailableUpload <- upload context session "personal" "unavailable.bin"
"72000000-0000-4000-8000-000000000001" "x"
requireError "Redis-unavailable upload"
@ -168,22 +170,22 @@ verifyDatabaseUnavailable context session = do
503 "mutation_rejected" unavailableDelete
putStrLn "Database-unavailable mutation behavior passed"
retryPastThrottle :: Int -> IO CurlResponse -> IO CurlResponse
retryPastThrottle :: Int -> IO ProbeResponse -> IO ProbeResponse
retryPastThrottle attempts action = do
response <- action
if attempts > 1
&& "\"error\":\"throttle_unavailable\"" `BS.isInfixOf` curlBody response
&& "\"error\":\"throttle_unavailable\"" `BS.isInfixOf` probeBody response
then threadDelay 250000 >> retryPastThrottle (attempts - 1) action
else pure response
requireSucceeded :: String -> CurlResponse -> IO ()
requireSucceeded :: String -> ProbeResponse -> IO ()
requireSucceeded label response = do
assertEqual (label <> " status") 200 (curlStatus response)
assertContains label "\"result\":\"succeeded\"" (curlBody response)
assertEqual (label <> " status") 200 (probeStatus response)
assertContains label "\"result\":\"succeeded\"" (probeBody response)
requireError :: String -> Int -> BS.ByteString -> CurlResponse -> IO ()
requireError :: String -> Int -> BS.ByteString -> ProbeResponse -> IO ()
requireError label status code response = do
assertEqual (label <> " status") status (curlStatus response)
assertEqual (label <> " status") status (probeStatus response)
assert
(label <> " body: " <> B8.unpack (curlBody response))
(("\"error\":\"" <> code <> "\"") `BS.isInfixOf` curlBody response)
(label <> " body: " <> B8.unpack (probeBody response))
(("\"error\":\"" <> code <> "\"") `BS.isInfixOf` probeBody response)