Type ordinary integration probe HTTP traffic

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

View file

@ -8,28 +8,27 @@ import qualified Data.ByteString.Char8 as B8
import Data.List (find)
import Nuthatch.Probe.Assert
import Nuthatch.Probe.Curl
import Nuthatch.Probe.Http
import Nuthatch.Probe.Oidc
import Nuthatch.Probe.ProductClient
import System.Entropy (getEntropy)
import System.IO.Temp (withSystemTempDirectory)
main :: IO ()
main =
withSystemTempDirectory "nuthatch-auth-probe" $ \directory -> do
context <- newProbeContext directory
runId <- B8.unpack . B16.encode <$> getEntropy 8
verifyCredentialScope
waitForApplication context
verifyPublicBoundary context
userA <- requireEnv "NUTHATCH_OIDC_USER_A"
sessionA <- login context True userA "a"
verifyPkceBoundary context userA
verifyAuthenticatedBoundary context sessionA
exerciseRoot context sessionA runId "personal" "01"
exerciseRoot context sessionA runId "shared" "02"
verifyCrossUserBoundary context sessionA runId
verifyLogout context sessionA
putStrLn "OIDC application boundary passed"
main = do
context <- newProbeContext
runId <- B8.unpack . B16.encode <$> getEntropy 8
verifyCredentialScope
waitForApplication context
verifyPublicBoundary context
userA <- requireEnv "NUTHATCH_OIDC_USER_A"
sessionA <- login context True userA "a"
verifyPkceBoundary context userA
verifyAuthenticatedBoundary context sessionA
exerciseRoot context sessionA runId "personal" "01"
exerciseRoot context sessionA runId "shared" "02"
verifyCrossUserBoundary context sessionA runId
verifyLogout context sessionA
putStrLn "OIDC application boundary passed"
waitForApplication :: ProbeContext -> IO ()
waitForApplication context = go 30
@ -37,16 +36,16 @@ waitForApplication context = go 30
go :: Int -> IO ()
go remaining = do
result <-
try (curl
[ "--connect-timeout", "1"
, "--max-time", "2"
, probeBase context <> "/readyz"
])
:: IO (Either CurlTransportFailure CurlResponse)
try
(getWith
(probeClient context)
defaultRequestOptions { optionTimeoutMicros = Just 2000000 }
(probeBase context <> "/readyz"))
:: IO (Either HttpException ProbeResponse)
case result of
Right _ -> pure ()
Left failure
| curlTransportExitCode failure == 7 && remaining > 1 ->
| isConnectionFailure failure && remaining > 1 ->
threadDelay 1000000 >> go (remaining - 1)
| otherwise -> throwIO failure
@ -63,9 +62,9 @@ verifyCredentialScope = do
denied label arguments = do
response <- curl (signed <> arguments)
assertEqual
(label <> ": " <> B8.unpack (curlBody response))
(label <> ": " <> B8.unpack (probeBody response))
403
(curlStatus response)
(probeStatus response)
emptyPayload =
"X-Amz-Content-Sha256:e3b0c44298fc1c149afbf4c8996fb924"
<> "27ae41e4649b934ca495991b7852b855"
@ -93,73 +92,89 @@ verifyCredentialScope = do
verifyPublicBoundary :: ProbeContext -> IO ()
verifyPublicBoundary context = do
health <- curl [probeBase context <> "/healthz"]
assertEqual "health status" 200 (curlStatus health)
assertContains "health response" "\"ok\":true" (curlBody health)
ready <- curl [probeBase context <> "/readyz"]
assertEqual "readiness status" 200 (curlStatus ready)
assertContains "readiness response" "\"ready\":true" (curlBody ready)
loginPost <- curl ["--request", "POST", probeBase context <> "/login"]
assertEqual "login method boundary" 405 (curlStatus loginPost)
root <- curl [probeBase context <> "/"]
assertEqual "root route boundary" 404 (curlStatus root)
downloadResponse <- curl
[ probeBase context
<> "/api/download?root=personal&path=unauthenticated"
]
assertEqual "unauthenticated download" 303 (curlStatus downloadResponse)
uploadResponse <- curl
[ "--header", "X-Declared-Size: 1"
, "--header", "X-Content-SHA256: " <> B8.unpack (sha256Hex "x")
, "--header"
, "X-CSRF-Token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
, "--data", "x"
, probeBase context
health <- get (probeClient context) (probeBase context <> "/healthz")
assertEqual "health status" 200 (probeStatus health)
assertContains "health response" "\"ok\":true" (probeBody health)
ready <- get (probeClient context) (probeBase context <> "/readyz")
assertEqual "readiness status" 200 (probeStatus ready)
assertContains "readiness response" "\"ready\":true" (probeBody ready)
loginPost <- postWith
(probeClient context)
defaultRequestOptions
(probeBase context <> "/login")
assertEqual "login method boundary" 405 (probeStatus loginPost)
root <- get (probeClient context) (probeBase context <> "/")
assertEqual "root route boundary" 404 (probeStatus root)
downloadResponse <- get
(probeClient context)
(probeBase context <> "/api/download?root=personal&path=unauthenticated")
assertEqual "unauthenticated download" 303 (probeStatus downloadResponse)
uploadResponse <- postWith
(probeClient context)
defaultRequestOptions
{ optionHeaders =
[ ("X-Declared-Size", "1")
, ("X-Content-SHA256", sha256Hex "x")
, ( "X-CSRF-Token"
, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
]
, optionBody = ByteBody "x"
}
(probeBase context
<> "/api/upload?root=personal&path=unauthenticated"
<> "&operation_id=40000000-0000-4000-8000-000000000001"
]
assertEqual "unauthenticated upload" 303 (curlStatus uploadResponse)
deleteResponse <- curl
[ "--header", "Content-Type: application/json"
, "--data"
, "{\"root\":\"personal\",\"path\":\"unauthenticated\","
<> "&operation_id=40000000-0000-4000-8000-000000000001")
assertEqual "unauthenticated upload" 303 (probeStatus uploadResponse)
deleteResponse <- postWith
(probeClient context)
defaultRequestOptions
{ optionHeaders = [("Content-Type", "application/json")]
, optionBody = ByteBody $
"{\"root\":\"personal\",\"path\":\"unauthenticated\","
<> "\"operation_id\":\"40000000-0000-4000-8000-000000000002\","
<> "\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
, probeBase context <> "/api/delete"
]
assertEqual "unauthenticated delete" 303 (curlStatus deleteResponse)
removedCompatibility <- curl
[ "--header", "Content-Type: application/json"
, "--data"
, "{\"root\":\"personal\",\"path\":\"unauthenticated\","
}
(probeBase context <> "/api/delete")
assertEqual "unauthenticated delete" 303 (probeStatus deleteResponse)
removedCompatibility <- postWith
(probeClient context)
defaultRequestOptions
{ optionHeaders = [("Content-Type", "application/json")]
, optionBody = ByteBody $
"{\"root\":\"personal\",\"path\":\"unauthenticated\","
<> "\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
, probeBase context <> "/delete-prefix"
]
assertEqual "removed compatibility route" 404 (curlStatus removedCompatibility)
logoutResponse <- curl
[ "--header"
, "X-CSRF-Token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
, "--request", "POST"
, probeBase context <> "/logout"
]
assertEqual "unauthenticated logout" 303 (curlStatus logoutResponse)
}
(probeBase context <> "/delete-prefix")
assertEqual "removed compatibility route" 404 (probeStatus removedCompatibility)
logoutResponse <- postWith
(probeClient context)
defaultRequestOptions
{ optionHeaders =
[ ( "X-CSRF-Token"
, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
]
}
(probeBase context <> "/logout")
assertEqual "unauthenticated logout" 303 (probeStatus logoutResponse)
verifyAuthenticatedBoundary :: ProbeContext -> Session -> IO ()
verifyAuthenticatedBoundary context session = do
html <- curl
[ "--cookie", sessionCookies session
, probeBase context <> "/api/browse"
]
assertEqual "authenticated HTML browse" 200 (curlStatus html)
html <- getWith
(probeClient context)
defaultRequestOptions
{ optionCookies = Just (sessionCookies session) }
(probeBase context <> "/api/browse")
assertEqual "authenticated HTML browse" 200 (probeStatus html)
json <- browse context session ""
assertEqual "authenticated JSON browse" 200 (curlStatus json)
assertEqual "authenticated JSON browse" 200 (probeStatus json)
let prohibited =
[ "NUTHATCH_S3"
, "AWS_ACCESS_KEY"
, "AWS_SECRET_ACCESS_KEY"
, "client_secret"
]
bodies = curlBody html <> curlBody json
bodies = probeBody html <> probeBody json
mapM_
(\value ->
assert ("browser response exposed " <> value) $
@ -182,18 +197,18 @@ exerciseRoot context session runId root suffix = do
firstUpload <- upload context session root path (operation "0") first
requireSucceeded "first upload" firstUpload
listing <- browse context session ("?root=" <> root)
assertContains "uploaded object listing" (B8.pack path) (curlBody listing)
assertContains "uploaded object listing" (B8.pack path) (probeBody listing)
firstDownload <- download context session root path
assertEqual "first download status" 200 (curlStatus firstDownload)
assertEqual "first download body" first (curlBody firstDownload)
assertEqual "first download status" 200 (probeStatus firstDownload)
assertEqual "first download body" first (probeBody firstDownload)
overwrite <- upload context session root path (operation "1") second
requireSucceeded "overwrite" overwrite
secondDownload <- download context session root path
assertEqual "overwrite body" second (curlBody secondDownload)
assertEqual "overwrite body" second (probeBody secondDownload)
deleted <- deleteObject context session root path (operation "2")
requireSucceeded "delete" deleted
missing <- download context session root path
assertEqual "deleted object" 404 (curlStatus missing)
assertEqual "deleted object" 404 (probeStatus missing)
verifyCrossUserBoundary :: ProbeContext -> Session -> String -> IO ()
verifyCrossUserBoundary context sessionA runId = do
@ -208,27 +223,32 @@ verifyCrossUserBoundary context sessionA runId = do
sessionB <- login context False userB "b"
personalB <- browse context sessionB "?root=personal"
assert "another user observed a personal object" $
not ("cross-user.txt" `BS.isInfixOf` curlBody personalB)
not ("cross-user.txt" `BS.isInfixOf` probeBody personalB)
sharedB <- browse context sessionB "?root=shared"
assertContains "shared object visibility" "cross-user.txt" (curlBody sharedB)
assertContains "shared object visibility" "cross-user.txt" (probeBody sharedB)
verifyLogout :: ProbeContext -> Session -> IO ()
verifyLogout context session = do
rejected <- curl
[ "--cookie", sessionCookies session
, "--header"
, "X-CSRF-Token: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
, "--request", "POST"
, probeBase context <> "/logout"
]
assert "logout accepted the wrong CSRF token" (curlStatus rejected >= 400)
completed <- curl
[ "--cookie", sessionCookies session
, "--header", "X-CSRF-Token: " <> B8.unpack (sessionCsrf session)
, "--request", "POST"
, probeBase context <> "/logout"
]
assertEqual "logout status" 200 (curlStatus completed)
rejected <- postWith
(probeClient context)
defaultRequestOptions
{ optionCookies = Just (sessionCookies session)
, optionHeaders =
[ ( "X-CSRF-Token"
, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
)
]
}
(probeBase context <> "/logout")
assert "logout accepted the wrong CSRF token" (probeStatus rejected >= 400)
completed <- postWith
(probeClient context)
defaultRequestOptions
{ optionCookies = Just (sessionCookies session)
, optionHeaders = [("X-CSRF-Token", sessionCsrf session)]
}
(probeBase context <> "/logout")
assertEqual "logout status" 200 (probeStatus completed)
mapM_
(\name ->
let matching =
@ -240,13 +260,14 @@ verifyLogout context session = do
(BS.isInfixOf "Max-Age=0")
matching)
["nuthatch_session", "nuthatch_csrf", "nuthatch_oidc"]
after <- curl
[ "--header", "Accept: application/json"
, probeBase context <> "/api/browse"
]
assertEqual "session after logout" 303 (curlStatus after)
after <- getWith
(probeClient context)
defaultRequestOptions
{ optionHeaders = [("Accept", "application/json")] }
(probeBase context <> "/api/browse")
assertEqual "session after logout" 303 (probeStatus after)
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)