Type ordinary integration probe HTTP traffic
This commit is contained in:
parent
390434bed1
commit
cc5c47f2ce
13 changed files with 1022 additions and 661 deletions
|
|
@ -49,7 +49,6 @@ RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& groupadd --system --gid 10001 nuthatch \
|
&& groupadd --system --gid 10001 nuthatch \
|
||||||
|
|
@ -62,6 +61,12 @@ USER 10001:10001
|
||||||
|
|
||||||
FROM runtime-base AS test-runtime
|
FROM runtime-base AS test-runtime
|
||||||
|
|
||||||
|
USER root
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
USER 10001:10001
|
||||||
|
|
||||||
COPY --from=build /out/nuthatch /usr/local/bin/nuthatch
|
COPY --from=build /out/nuthatch /usr/local/bin/nuthatch
|
||||||
COPY --from=build /out/nuthatch-storage-integration /usr/local/bin/nuthatch-storage-integration
|
COPY --from=build /out/nuthatch-storage-integration /usr/local/bin/nuthatch-storage-integration
|
||||||
COPY --from=build /out/nuthatch-recovery-probe /usr/local/bin/nuthatch-recovery-probe
|
COPY --from=build /out/nuthatch-recovery-probe /usr/local/bin/nuthatch-recovery-probe
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ common shared
|
||||||
, containers >= 0.6 && < 0.8
|
, containers >= 0.6 && < 0.8
|
||||||
, conduit >= 1.3 && < 1.4
|
, conduit >= 1.3 && < 1.4
|
||||||
, cookie >= 0.5.1 && < 0.6
|
, cookie >= 0.5.1 && < 0.6
|
||||||
|
, crypton-connection >= 0.4 && < 0.5
|
||||||
, crypton >= 0.34 && < 1.1
|
, crypton >= 0.34 && < 1.1
|
||||||
, directory >= 1.3 && < 1.4
|
, directory >= 1.3 && < 1.4
|
||||||
, entropy >= 0.4 && < 0.5
|
, entropy >= 0.4 && < 0.5
|
||||||
|
|
@ -88,6 +89,7 @@ common shared
|
||||||
, temporary >= 1.3 && < 1.4
|
, temporary >= 1.3 && < 1.4
|
||||||
, text >= 2.0 && < 2.2
|
, text >= 2.0 && < 2.2
|
||||||
, time >= 1.12 && < 1.15
|
, time >= 1.12 && < 1.15
|
||||||
|
, tls >= 2.2 && < 2.3
|
||||||
, transformers >= 0.6 && < 0.7
|
, transformers >= 0.6 && < 0.7
|
||||||
, unicode-transforms >= 0.4 && < 0.5
|
, unicode-transforms >= 0.4 && < 0.5
|
||||||
, unordered-containers >= 0.2 && < 0.3
|
, unordered-containers >= 0.2 && < 0.3
|
||||||
|
|
@ -242,6 +244,7 @@ executable nuthatch-auth-probe
|
||||||
other-modules:
|
other-modules:
|
||||||
Nuthatch.Probe.Assert
|
Nuthatch.Probe.Assert
|
||||||
Nuthatch.Probe.Curl
|
Nuthatch.Probe.Curl
|
||||||
|
Nuthatch.Probe.Http
|
||||||
Nuthatch.Probe.Oidc
|
Nuthatch.Probe.Oidc
|
||||||
Nuthatch.Probe.ProductClient
|
Nuthatch.Probe.ProductClient
|
||||||
build-depends:
|
build-depends:
|
||||||
|
|
@ -264,7 +267,7 @@ executable nuthatch-product-probe
|
||||||
hs-source-dirs: test
|
hs-source-dirs: test
|
||||||
other-modules:
|
other-modules:
|
||||||
Nuthatch.Probe.Assert
|
Nuthatch.Probe.Assert
|
||||||
Nuthatch.Probe.Curl
|
Nuthatch.Probe.Http
|
||||||
Nuthatch.Probe.Oidc
|
Nuthatch.Probe.Oidc
|
||||||
Nuthatch.Probe.ProductClient
|
Nuthatch.Probe.ProductClient
|
||||||
build-depends:
|
build-depends:
|
||||||
|
|
@ -288,6 +291,7 @@ executable nuthatch-idempotency-probe
|
||||||
other-modules:
|
other-modules:
|
||||||
Nuthatch.Probe.Assert
|
Nuthatch.Probe.Assert
|
||||||
Nuthatch.Probe.Curl
|
Nuthatch.Probe.Curl
|
||||||
|
Nuthatch.Probe.Http
|
||||||
Nuthatch.Probe.Oidc
|
Nuthatch.Probe.Oidc
|
||||||
Nuthatch.Probe.ProductClient
|
Nuthatch.Probe.ProductClient
|
||||||
build-depends:
|
build-depends:
|
||||||
|
|
@ -312,6 +316,7 @@ executable nuthatch-streaming-probe
|
||||||
other-modules:
|
other-modules:
|
||||||
Nuthatch.Probe.Assert
|
Nuthatch.Probe.Assert
|
||||||
Nuthatch.Probe.Curl
|
Nuthatch.Probe.Curl
|
||||||
|
Nuthatch.Probe.Http
|
||||||
Nuthatch.Probe.Oidc
|
Nuthatch.Probe.Oidc
|
||||||
Nuthatch.Probe.ProductClient
|
Nuthatch.Probe.ProductClient
|
||||||
build-depends:
|
build-depends:
|
||||||
|
|
@ -336,7 +341,7 @@ executable nuthatch-runtime-http-probe
|
||||||
hs-source-dirs: test
|
hs-source-dirs: test
|
||||||
other-modules:
|
other-modules:
|
||||||
Nuthatch.Probe.Assert
|
Nuthatch.Probe.Assert
|
||||||
Nuthatch.Probe.Curl
|
Nuthatch.Probe.Http
|
||||||
Nuthatch.Probe.Oidc
|
Nuthatch.Probe.Oidc
|
||||||
Nuthatch.Probe.ProductClient
|
Nuthatch.Probe.ProductClient
|
||||||
build-depends:
|
build-depends:
|
||||||
|
|
@ -372,7 +377,7 @@ executable nuthatch-fixture-probe
|
||||||
hs-source-dirs: test
|
hs-source-dirs: test
|
||||||
other-modules:
|
other-modules:
|
||||||
Nuthatch.Probe.Assert
|
Nuthatch.Probe.Assert
|
||||||
Nuthatch.Probe.Curl
|
Nuthatch.Probe.Http
|
||||||
build-depends:
|
build-depends:
|
||||||
, nuthatch
|
, nuthatch
|
||||||
, base
|
, base
|
||||||
|
|
@ -391,6 +396,7 @@ executable nuthatch-dependency-probe
|
||||||
other-modules:
|
other-modules:
|
||||||
Nuthatch.Probe.Assert
|
Nuthatch.Probe.Assert
|
||||||
Nuthatch.Probe.Curl
|
Nuthatch.Probe.Curl
|
||||||
|
Nuthatch.Probe.Http
|
||||||
build-depends:
|
build-depends:
|
||||||
, nuthatch
|
, nuthatch
|
||||||
, base
|
, base
|
||||||
|
|
|
||||||
|
|
@ -8,28 +8,27 @@ import qualified Data.ByteString.Char8 as B8
|
||||||
import Data.List (find)
|
import Data.List (find)
|
||||||
import Nuthatch.Probe.Assert
|
import Nuthatch.Probe.Assert
|
||||||
import Nuthatch.Probe.Curl
|
import Nuthatch.Probe.Curl
|
||||||
|
import Nuthatch.Probe.Http
|
||||||
import Nuthatch.Probe.Oidc
|
import Nuthatch.Probe.Oidc
|
||||||
import Nuthatch.Probe.ProductClient
|
import Nuthatch.Probe.ProductClient
|
||||||
import System.Entropy (getEntropy)
|
import System.Entropy (getEntropy)
|
||||||
import System.IO.Temp (withSystemTempDirectory)
|
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main =
|
main = do
|
||||||
withSystemTempDirectory "nuthatch-auth-probe" $ \directory -> do
|
context <- newProbeContext
|
||||||
context <- newProbeContext directory
|
runId <- B8.unpack . B16.encode <$> getEntropy 8
|
||||||
runId <- B8.unpack . B16.encode <$> getEntropy 8
|
verifyCredentialScope
|
||||||
verifyCredentialScope
|
waitForApplication context
|
||||||
waitForApplication context
|
verifyPublicBoundary context
|
||||||
verifyPublicBoundary context
|
userA <- requireEnv "NUTHATCH_OIDC_USER_A"
|
||||||
userA <- requireEnv "NUTHATCH_OIDC_USER_A"
|
sessionA <- login context True userA "a"
|
||||||
sessionA <- login context True userA "a"
|
verifyPkceBoundary context userA
|
||||||
verifyPkceBoundary context userA
|
verifyAuthenticatedBoundary context sessionA
|
||||||
verifyAuthenticatedBoundary context sessionA
|
exerciseRoot context sessionA runId "personal" "01"
|
||||||
exerciseRoot context sessionA runId "personal" "01"
|
exerciseRoot context sessionA runId "shared" "02"
|
||||||
exerciseRoot context sessionA runId "shared" "02"
|
verifyCrossUserBoundary context sessionA runId
|
||||||
verifyCrossUserBoundary context sessionA runId
|
verifyLogout context sessionA
|
||||||
verifyLogout context sessionA
|
putStrLn "OIDC application boundary passed"
|
||||||
putStrLn "OIDC application boundary passed"
|
|
||||||
|
|
||||||
waitForApplication :: ProbeContext -> IO ()
|
waitForApplication :: ProbeContext -> IO ()
|
||||||
waitForApplication context = go 30
|
waitForApplication context = go 30
|
||||||
|
|
@ -37,16 +36,16 @@ waitForApplication context = go 30
|
||||||
go :: Int -> IO ()
|
go :: Int -> IO ()
|
||||||
go remaining = do
|
go remaining = do
|
||||||
result <-
|
result <-
|
||||||
try (curl
|
try
|
||||||
[ "--connect-timeout", "1"
|
(getWith
|
||||||
, "--max-time", "2"
|
(probeClient context)
|
||||||
, probeBase context <> "/readyz"
|
defaultRequestOptions { optionTimeoutMicros = Just 2000000 }
|
||||||
])
|
(probeBase context <> "/readyz"))
|
||||||
:: IO (Either CurlTransportFailure CurlResponse)
|
:: IO (Either HttpException ProbeResponse)
|
||||||
case result of
|
case result of
|
||||||
Right _ -> pure ()
|
Right _ -> pure ()
|
||||||
Left failure
|
Left failure
|
||||||
| curlTransportExitCode failure == 7 && remaining > 1 ->
|
| isConnectionFailure failure && remaining > 1 ->
|
||||||
threadDelay 1000000 >> go (remaining - 1)
|
threadDelay 1000000 >> go (remaining - 1)
|
||||||
| otherwise -> throwIO failure
|
| otherwise -> throwIO failure
|
||||||
|
|
||||||
|
|
@ -63,9 +62,9 @@ verifyCredentialScope = do
|
||||||
denied label arguments = do
|
denied label arguments = do
|
||||||
response <- curl (signed <> arguments)
|
response <- curl (signed <> arguments)
|
||||||
assertEqual
|
assertEqual
|
||||||
(label <> ": " <> B8.unpack (curlBody response))
|
(label <> ": " <> B8.unpack (probeBody response))
|
||||||
403
|
403
|
||||||
(curlStatus response)
|
(probeStatus response)
|
||||||
emptyPayload =
|
emptyPayload =
|
||||||
"X-Amz-Content-Sha256:e3b0c44298fc1c149afbf4c8996fb924"
|
"X-Amz-Content-Sha256:e3b0c44298fc1c149afbf4c8996fb924"
|
||||||
<> "27ae41e4649b934ca495991b7852b855"
|
<> "27ae41e4649b934ca495991b7852b855"
|
||||||
|
|
@ -93,73 +92,89 @@ verifyCredentialScope = do
|
||||||
|
|
||||||
verifyPublicBoundary :: ProbeContext -> IO ()
|
verifyPublicBoundary :: ProbeContext -> IO ()
|
||||||
verifyPublicBoundary context = do
|
verifyPublicBoundary context = do
|
||||||
health <- curl [probeBase context <> "/healthz"]
|
health <- get (probeClient context) (probeBase context <> "/healthz")
|
||||||
assertEqual "health status" 200 (curlStatus health)
|
assertEqual "health status" 200 (probeStatus health)
|
||||||
assertContains "health response" "\"ok\":true" (curlBody health)
|
assertContains "health response" "\"ok\":true" (probeBody health)
|
||||||
ready <- curl [probeBase context <> "/readyz"]
|
ready <- get (probeClient context) (probeBase context <> "/readyz")
|
||||||
assertEqual "readiness status" 200 (curlStatus ready)
|
assertEqual "readiness status" 200 (probeStatus ready)
|
||||||
assertContains "readiness response" "\"ready\":true" (curlBody ready)
|
assertContains "readiness response" "\"ready\":true" (probeBody ready)
|
||||||
loginPost <- curl ["--request", "POST", probeBase context <> "/login"]
|
loginPost <- postWith
|
||||||
assertEqual "login method boundary" 405 (curlStatus loginPost)
|
(probeClient context)
|
||||||
root <- curl [probeBase context <> "/"]
|
defaultRequestOptions
|
||||||
assertEqual "root route boundary" 404 (curlStatus root)
|
(probeBase context <> "/login")
|
||||||
downloadResponse <- curl
|
assertEqual "login method boundary" 405 (probeStatus loginPost)
|
||||||
[ probeBase context
|
root <- get (probeClient context) (probeBase context <> "/")
|
||||||
<> "/api/download?root=personal&path=unauthenticated"
|
assertEqual "root route boundary" 404 (probeStatus root)
|
||||||
]
|
downloadResponse <- get
|
||||||
assertEqual "unauthenticated download" 303 (curlStatus downloadResponse)
|
(probeClient context)
|
||||||
uploadResponse <- curl
|
(probeBase context <> "/api/download?root=personal&path=unauthenticated")
|
||||||
[ "--header", "X-Declared-Size: 1"
|
assertEqual "unauthenticated download" 303 (probeStatus downloadResponse)
|
||||||
, "--header", "X-Content-SHA256: " <> B8.unpack (sha256Hex "x")
|
uploadResponse <- postWith
|
||||||
, "--header"
|
(probeClient context)
|
||||||
, "X-CSRF-Token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
defaultRequestOptions
|
||||||
, "--data", "x"
|
{ optionHeaders =
|
||||||
, probeBase context
|
[ ("X-Declared-Size", "1")
|
||||||
|
, ("X-Content-SHA256", sha256Hex "x")
|
||||||
|
, ( "X-CSRF-Token"
|
||||||
|
, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
, optionBody = ByteBody "x"
|
||||||
|
}
|
||||||
|
(probeBase context
|
||||||
<> "/api/upload?root=personal&path=unauthenticated"
|
<> "/api/upload?root=personal&path=unauthenticated"
|
||||||
<> "&operation_id=40000000-0000-4000-8000-000000000001"
|
<> "&operation_id=40000000-0000-4000-8000-000000000001")
|
||||||
]
|
assertEqual "unauthenticated upload" 303 (probeStatus uploadResponse)
|
||||||
assertEqual "unauthenticated upload" 303 (curlStatus uploadResponse)
|
deleteResponse <- postWith
|
||||||
deleteResponse <- curl
|
(probeClient context)
|
||||||
[ "--header", "Content-Type: application/json"
|
defaultRequestOptions
|
||||||
, "--data"
|
{ optionHeaders = [("Content-Type", "application/json")]
|
||||||
, "{\"root\":\"personal\",\"path\":\"unauthenticated\","
|
, optionBody = ByteBody $
|
||||||
|
"{\"root\":\"personal\",\"path\":\"unauthenticated\","
|
||||||
<> "\"operation_id\":\"40000000-0000-4000-8000-000000000002\","
|
<> "\"operation_id\":\"40000000-0000-4000-8000-000000000002\","
|
||||||
<> "\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
|
<> "\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
|
||||||
, probeBase context <> "/api/delete"
|
}
|
||||||
]
|
(probeBase context <> "/api/delete")
|
||||||
assertEqual "unauthenticated delete" 303 (curlStatus deleteResponse)
|
assertEqual "unauthenticated delete" 303 (probeStatus deleteResponse)
|
||||||
removedCompatibility <- curl
|
removedCompatibility <- postWith
|
||||||
[ "--header", "Content-Type: application/json"
|
(probeClient context)
|
||||||
, "--data"
|
defaultRequestOptions
|
||||||
, "{\"root\":\"personal\",\"path\":\"unauthenticated\","
|
{ optionHeaders = [("Content-Type", "application/json")]
|
||||||
|
, optionBody = ByteBody $
|
||||||
|
"{\"root\":\"personal\",\"path\":\"unauthenticated\","
|
||||||
<> "\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
|
<> "\"csrf\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"
|
||||||
, probeBase context <> "/delete-prefix"
|
}
|
||||||
]
|
(probeBase context <> "/delete-prefix")
|
||||||
assertEqual "removed compatibility route" 404 (curlStatus removedCompatibility)
|
assertEqual "removed compatibility route" 404 (probeStatus removedCompatibility)
|
||||||
logoutResponse <- curl
|
logoutResponse <- postWith
|
||||||
[ "--header"
|
(probeClient context)
|
||||||
, "X-CSRF-Token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
defaultRequestOptions
|
||||||
, "--request", "POST"
|
{ optionHeaders =
|
||||||
, probeBase context <> "/logout"
|
[ ( "X-CSRF-Token"
|
||||||
]
|
, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
assertEqual "unauthenticated logout" 303 (curlStatus logoutResponse)
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
(probeBase context <> "/logout")
|
||||||
|
assertEqual "unauthenticated logout" 303 (probeStatus logoutResponse)
|
||||||
|
|
||||||
verifyAuthenticatedBoundary :: ProbeContext -> Session -> IO ()
|
verifyAuthenticatedBoundary :: ProbeContext -> Session -> IO ()
|
||||||
verifyAuthenticatedBoundary context session = do
|
verifyAuthenticatedBoundary context session = do
|
||||||
html <- curl
|
html <- getWith
|
||||||
[ "--cookie", sessionCookies session
|
(probeClient context)
|
||||||
, probeBase context <> "/api/browse"
|
defaultRequestOptions
|
||||||
]
|
{ optionCookies = Just (sessionCookies session) }
|
||||||
assertEqual "authenticated HTML browse" 200 (curlStatus html)
|
(probeBase context <> "/api/browse")
|
||||||
|
assertEqual "authenticated HTML browse" 200 (probeStatus html)
|
||||||
json <- browse context session ""
|
json <- browse context session ""
|
||||||
assertEqual "authenticated JSON browse" 200 (curlStatus json)
|
assertEqual "authenticated JSON browse" 200 (probeStatus json)
|
||||||
let prohibited =
|
let prohibited =
|
||||||
[ "NUTHATCH_S3"
|
[ "NUTHATCH_S3"
|
||||||
, "AWS_ACCESS_KEY"
|
, "AWS_ACCESS_KEY"
|
||||||
, "AWS_SECRET_ACCESS_KEY"
|
, "AWS_SECRET_ACCESS_KEY"
|
||||||
, "client_secret"
|
, "client_secret"
|
||||||
]
|
]
|
||||||
bodies = curlBody html <> curlBody json
|
bodies = probeBody html <> probeBody json
|
||||||
mapM_
|
mapM_
|
||||||
(\value ->
|
(\value ->
|
||||||
assert ("browser response exposed " <> 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
|
firstUpload <- upload context session root path (operation "0") first
|
||||||
requireSucceeded "first upload" firstUpload
|
requireSucceeded "first upload" firstUpload
|
||||||
listing <- browse context session ("?root=" <> root)
|
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
|
firstDownload <- download context session root path
|
||||||
assertEqual "first download status" 200 (curlStatus firstDownload)
|
assertEqual "first download status" 200 (probeStatus firstDownload)
|
||||||
assertEqual "first download body" first (curlBody firstDownload)
|
assertEqual "first download body" first (probeBody firstDownload)
|
||||||
overwrite <- upload context session root path (operation "1") second
|
overwrite <- upload context session root path (operation "1") second
|
||||||
requireSucceeded "overwrite" overwrite
|
requireSucceeded "overwrite" overwrite
|
||||||
secondDownload <- download context session root path
|
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")
|
deleted <- deleteObject context session root path (operation "2")
|
||||||
requireSucceeded "delete" deleted
|
requireSucceeded "delete" deleted
|
||||||
missing <- download context session root path
|
missing <- download context session root path
|
||||||
assertEqual "deleted object" 404 (curlStatus missing)
|
assertEqual "deleted object" 404 (probeStatus missing)
|
||||||
|
|
||||||
verifyCrossUserBoundary :: ProbeContext -> Session -> String -> IO ()
|
verifyCrossUserBoundary :: ProbeContext -> Session -> String -> IO ()
|
||||||
verifyCrossUserBoundary context sessionA runId = do
|
verifyCrossUserBoundary context sessionA runId = do
|
||||||
|
|
@ -208,27 +223,32 @@ verifyCrossUserBoundary context sessionA runId = do
|
||||||
sessionB <- login context False userB "b"
|
sessionB <- login context False userB "b"
|
||||||
personalB <- browse context sessionB "?root=personal"
|
personalB <- browse context sessionB "?root=personal"
|
||||||
assert "another user observed a personal object" $
|
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"
|
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 :: ProbeContext -> Session -> IO ()
|
||||||
verifyLogout context session = do
|
verifyLogout context session = do
|
||||||
rejected <- curl
|
rejected <- postWith
|
||||||
[ "--cookie", sessionCookies session
|
(probeClient context)
|
||||||
, "--header"
|
defaultRequestOptions
|
||||||
, "X-CSRF-Token: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
{ optionCookies = Just (sessionCookies session)
|
||||||
, "--request", "POST"
|
, optionHeaders =
|
||||||
, probeBase context <> "/logout"
|
[ ( "X-CSRF-Token"
|
||||||
]
|
, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
assert "logout accepted the wrong CSRF token" (curlStatus rejected >= 400)
|
)
|
||||||
completed <- curl
|
]
|
||||||
[ "--cookie", sessionCookies session
|
}
|
||||||
, "--header", "X-CSRF-Token: " <> B8.unpack (sessionCsrf session)
|
(probeBase context <> "/logout")
|
||||||
, "--request", "POST"
|
assert "logout accepted the wrong CSRF token" (probeStatus rejected >= 400)
|
||||||
, probeBase context <> "/logout"
|
completed <- postWith
|
||||||
]
|
(probeClient context)
|
||||||
assertEqual "logout status" 200 (curlStatus completed)
|
defaultRequestOptions
|
||||||
|
{ optionCookies = Just (sessionCookies session)
|
||||||
|
, optionHeaders = [("X-CSRF-Token", sessionCsrf session)]
|
||||||
|
}
|
||||||
|
(probeBase context <> "/logout")
|
||||||
|
assertEqual "logout status" 200 (probeStatus completed)
|
||||||
mapM_
|
mapM_
|
||||||
(\name ->
|
(\name ->
|
||||||
let matching =
|
let matching =
|
||||||
|
|
@ -240,13 +260,14 @@ verifyLogout context session = do
|
||||||
(BS.isInfixOf "Max-Age=0")
|
(BS.isInfixOf "Max-Age=0")
|
||||||
matching)
|
matching)
|
||||||
["nuthatch_session", "nuthatch_csrf", "nuthatch_oidc"]
|
["nuthatch_session", "nuthatch_csrf", "nuthatch_oidc"]
|
||||||
after <- curl
|
after <- getWith
|
||||||
[ "--header", "Accept: application/json"
|
(probeClient context)
|
||||||
, probeBase context <> "/api/browse"
|
defaultRequestOptions
|
||||||
]
|
{ optionHeaders = [("Accept", "application/json")] }
|
||||||
assertEqual "session after logout" 303 (curlStatus after)
|
(probeBase context <> "/api/browse")
|
||||||
|
assertEqual "session after logout" 303 (probeStatus after)
|
||||||
|
|
||||||
requireSucceeded :: String -> CurlResponse -> IO ()
|
requireSucceeded :: String -> ProbeResponse -> IO ()
|
||||||
requireSucceeded label response = do
|
requireSucceeded label response = do
|
||||||
assertEqual (label <> " status") 200 (curlStatus response)
|
assertEqual (label <> " status") 200 (probeStatus response)
|
||||||
assertContains label "\"result\":\"succeeded\"" (curlBody response)
|
assertContains label "\"result\":\"succeeded\"" (probeBody response)
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,16 @@ import Database.Redis
|
||||||
)
|
)
|
||||||
import Nuthatch.Probe.Assert
|
import Nuthatch.Probe.Assert
|
||||||
import Nuthatch.Probe.Curl
|
import Nuthatch.Probe.Curl
|
||||||
|
import Nuthatch.Probe.Http hiding (get)
|
||||||
|
import qualified Nuthatch.Probe.Http as Http
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
|
client <- newProbeClient
|
||||||
verifyPostgres
|
verifyPostgres
|
||||||
verifyCoordination "redis"
|
verifyCoordination "redis"
|
||||||
verifyCoordination "dragonfly"
|
verifyCoordination "dragonfly"
|
||||||
verifyOidc
|
verifyOidc client
|
||||||
verifyTarget
|
verifyTarget
|
||||||
"Garage"
|
"Garage"
|
||||||
"http://garage:3900"
|
"http://garage:3900"
|
||||||
|
|
@ -84,15 +87,15 @@ verifyCoordination host =
|
||||||
(Right (Just "passed"))
|
(Right (Just "passed"))
|
||||||
loaded)
|
loaded)
|
||||||
|
|
||||||
verifyOidc :: IO ()
|
verifyOidc :: ProbeClient -> IO ()
|
||||||
verifyOidc =
|
verifyOidc client =
|
||||||
mapM_
|
mapM_
|
||||||
(\(label, url, marker) ->
|
(\(label, url, marker) ->
|
||||||
retry label 30 $ do
|
retry label 30 $ do
|
||||||
response <- curl [url]
|
response <- Http.get client url
|
||||||
pure
|
pure
|
||||||
( curlStatus response == 200
|
( probeStatus response == 200
|
||||||
&& marker `B8.isInfixOf` curlBody response
|
&& marker `B8.isInfixOf` probeBody response
|
||||||
))
|
))
|
||||||
[ ( "Keycloak discovery"
|
[ ( "Keycloak discovery"
|
||||||
, "http://keycloak.nuthatch.test:8080/realms/master/.well-known/openid-configuration"
|
, "http://keycloak.nuthatch.test:8080/realms/master/.well-known/openid-configuration"
|
||||||
|
|
@ -144,9 +147,9 @@ verifyTarget label endpoint region accessKey secretKey createBucket = do
|
||||||
assert
|
assert
|
||||||
( label
|
( label
|
||||||
<> " bucket creation failed: "
|
<> " bucket creation failed: "
|
||||||
<> B8.unpack (curlBody created)
|
<> B8.unpack (probeBody created)
|
||||||
) $
|
) $
|
||||||
curlStatus created `elem` [200, 409]
|
probeStatus created `elem` [200, 409]
|
||||||
else pure ()
|
else pure ()
|
||||||
uploaded <- retryResponse 30 $ curl
|
uploaded <- retryResponse 30 $ curl
|
||||||
( signed
|
( signed
|
||||||
|
|
@ -158,9 +161,9 @@ verifyTarget label endpoint region accessKey secretKey createBucket = do
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assertEqual
|
assertEqual
|
||||||
(label <> " upload: " <> B8.unpack (curlBody uploaded))
|
(label <> " upload: " <> B8.unpack (probeBody uploaded))
|
||||||
200
|
200
|
||||||
(curlStatus uploaded)
|
(probeStatus uploaded)
|
||||||
downloaded <- curl
|
downloaded <- curl
|
||||||
( signed
|
( signed
|
||||||
<> [ "--header"
|
<> [ "--header"
|
||||||
|
|
@ -168,10 +171,10 @@ verifyTarget label endpoint region accessKey secretKey createBucket = do
|
||||||
, bucket <> "/probe/payload"
|
, bucket <> "/probe/payload"
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assertEqual (label <> " download status") 200 (curlStatus downloaded)
|
assertEqual (label <> " download status") 200 (probeStatus downloaded)
|
||||||
assertEqual (label <> " object body")
|
assertEqual (label <> " object body")
|
||||||
(B8.pack payload)
|
(B8.pack payload)
|
||||||
(curlBody downloaded)
|
(probeBody downloaded)
|
||||||
|
|
||||||
retry :: String -> Int -> IO Bool -> IO ()
|
retry :: String -> Int -> IO Bool -> IO ()
|
||||||
retry label attempts action =
|
retry label attempts action =
|
||||||
|
|
@ -184,13 +187,13 @@ retry label attempts action =
|
||||||
then fail (label <> " did not become available")
|
then fail (label <> " did not become available")
|
||||||
else threadDelay 1000000 >> go (remaining - 1)
|
else threadDelay 1000000 >> go (remaining - 1)
|
||||||
|
|
||||||
retryResponse :: Int -> IO CurlResponse -> IO CurlResponse
|
retryResponse :: Int -> IO ProbeResponse -> IO ProbeResponse
|
||||||
retryResponse attempts action =
|
retryResponse attempts action =
|
||||||
go attempts
|
go attempts
|
||||||
where
|
where
|
||||||
go remaining = do
|
go remaining = do
|
||||||
response <- action
|
response <- action
|
||||||
if curlStatus response == 200 || remaining <= 1
|
if probeStatus response == 200 || remaining <= 1
|
||||||
then pure response
|
then pure response
|
||||||
else threadDelay 1000000 >> go (remaining - 1)
|
else threadDelay 1000000 >> go (remaining - 1)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import Database.PostgreSQL.Simple (Only (..))
|
||||||
import Nuthatch.Config (Config (..), loadConfig)
|
import Nuthatch.Config (Config (..), loadConfig)
|
||||||
import qualified Nuthatch.Db as Db
|
import qualified Nuthatch.Db as Db
|
||||||
import Nuthatch.Probe.Assert
|
import Nuthatch.Probe.Assert
|
||||||
import Nuthatch.Probe.Curl
|
import Nuthatch.Probe.Http
|
||||||
import Nuthatch.Redis
|
import Nuthatch.Redis
|
||||||
import Nuthatch.S3
|
import Nuthatch.S3
|
||||||
import Nuthatch.Types
|
import Nuthatch.Types
|
||||||
|
|
@ -19,6 +19,7 @@ import System.Environment (setEnv)
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
|
client <- newProbeClient
|
||||||
profile <- requireEnv "NUTHATCH_FIXTURE_PROFILE"
|
profile <- requireEnv "NUTHATCH_FIXTURE_PROFILE"
|
||||||
lane <- requireEnv "NUTHATCH_FIXTURE_LANE"
|
lane <- requireEnv "NUTHATCH_FIXTURE_LANE"
|
||||||
pgConnection <- requireEnv "NUTHATCH_PG_CONN"
|
pgConnection <- requireEnv "NUTHATCH_PG_CONN"
|
||||||
|
|
@ -39,9 +40,9 @@ main = do
|
||||||
cacheGet redis "lane-canary" >>=
|
cacheGet redis "lane-canary" >>=
|
||||||
assertEqual "coordination lane canary" (Just (B8.pack profile))
|
assertEqual "coordination lane canary" (Just (B8.pack profile))
|
||||||
issuer <- requireEnv "NUTHATCH_OIDC_ISSUER"
|
issuer <- requireEnv "NUTHATCH_OIDC_ISSUER"
|
||||||
discovery <- curl [issuer <> "/.well-known/openid-configuration"]
|
discovery <- get client (issuer <> "/.well-known/openid-configuration")
|
||||||
assertEqual "OIDC discovery status" 200 (curlStatus discovery)
|
assertEqual "OIDC discovery status" 200 (probeStatus discovery)
|
||||||
assertContains "OIDC discovery issuer" "\"issuer\"" (curlBody discovery)
|
assertContains "OIDC discovery issuer" "\"issuer\"" (probeBody discovery)
|
||||||
store <- newS3 (cfgS3 config)
|
store <- newS3 (cfgS3 config)
|
||||||
let key = ObjectKey "lane-canary"
|
let key = ObjectKey "lane-canary"
|
||||||
body = BL.fromStrict (B8.pack profile)
|
body = BL.fromStrict (B8.pack profile)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Char8 as B8
|
import qualified Data.ByteString.Char8 as B8
|
||||||
import Nuthatch.Probe.Assert
|
import Nuthatch.Probe.Assert
|
||||||
import Nuthatch.Probe.Curl
|
import Nuthatch.Probe.Curl
|
||||||
|
import Nuthatch.Probe.Http
|
||||||
import Nuthatch.Probe.Oidc
|
import Nuthatch.Probe.Oidc
|
||||||
import Nuthatch.Probe.ProductClient
|
import Nuthatch.Probe.ProductClient
|
||||||
import System.Exit (ExitCode (..))
|
import System.Exit (ExitCode (..))
|
||||||
|
|
@ -25,7 +26,7 @@ import System.Process
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main =
|
main =
|
||||||
withSystemTempDirectory "nuthatch-idempotency-probe" $ \directory -> do
|
withSystemTempDirectory "nuthatch-idempotency-probe" $ \directory -> do
|
||||||
context <- newProbeContext directory
|
context <- newProbeContext
|
||||||
user <- requireEnv "NUTHATCH_OIDC_USER_A"
|
user <- requireEnv "NUTHATCH_OIDC_USER_A"
|
||||||
session <- login context False user "idempotency"
|
session <- login context False user "idempotency"
|
||||||
lane <- requireEnv "NUTHATCH_FIXTURE_LANE"
|
lane <- requireEnv "NUTHATCH_FIXTURE_LANE"
|
||||||
|
|
@ -53,16 +54,16 @@ verifyEpochs context session lane ipA ipB = do
|
||||||
conflict <- targetedUpload context session lane ipB operationId "conflict"
|
conflict <- targetedUpload context session lane ipB operationId "conflict"
|
||||||
assertContains "conflicting reuse"
|
assertContains "conflicting reuse"
|
||||||
"\"result\":\"conflict\""
|
"\"result\":\"conflict\""
|
||||||
(curlBody conflict)
|
(probeBody conflict)
|
||||||
threadDelay 3000000
|
threadDelay 3000000
|
||||||
(responseA, responseB) <- concurrently
|
(responseA, responseB) <- concurrently
|
||||||
(targetedUpload context session lane ipA operationId "second-epoch-a")
|
(targetedUpload context session lane ipA operationId "second-epoch-a")
|
||||||
(targetedUpload context session lane ipB operationId "second-epoch-b")
|
(targetedUpload context session lane ipB operationId "second-epoch-b")
|
||||||
let outcomes = [responseA, responseB]
|
let outcomes = [responseA, responseB]
|
||||||
succeeded response =
|
succeeded response =
|
||||||
"\"result\":\"succeeded\"" `BS.isInfixOf` curlBody response
|
"\"result\":\"succeeded\"" `BS.isInfixOf` probeBody response
|
||||||
conflicted response =
|
conflicted response =
|
||||||
"\"result\":\"conflict\"" `BS.isInfixOf` curlBody response
|
"\"result\":\"conflict\"" `BS.isInfixOf` probeBody response
|
||||||
assertEqual "new epoch winner count" 1 (length (filter succeeded outcomes))
|
assertEqual "new epoch winner count" 1 (length (filter succeeded outcomes))
|
||||||
assertEqual "new epoch conflict count" 1 (length (filter conflicted outcomes))
|
assertEqual "new epoch conflict count" 1 (length (filter conflicted outcomes))
|
||||||
let (winningBody, replayIp) =
|
let (winningBody, replayIp) =
|
||||||
|
|
@ -72,13 +73,9 @@ verifyEpochs context session lane ipA ipB = do
|
||||||
secondReplay <-
|
secondReplay <-
|
||||||
targetedUpload context session lane replayIp operationId winningBody
|
targetedUpload context session lane replayIp operationId winningBody
|
||||||
requireSucceededWindow "new epoch replay" secondReplay
|
requireSucceededWindow "new epoch replay" secondReplay
|
||||||
downloaded <- downloadFrom
|
downloaded <-
|
||||||
(probeBase context)
|
targetedDownload context session lane ipA "personal" "idempotency.txt"
|
||||||
(resolveArgs lane ipA)
|
assertEqual "new epoch object body" winningBody (probeBody downloaded)
|
||||||
session
|
|
||||||
"personal"
|
|
||||||
"idempotency.txt"
|
|
||||||
assertEqual "new epoch object body" winningBody (curlBody downloaded)
|
|
||||||
|
|
||||||
verifyActiveIdentity
|
verifyActiveIdentity
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
|
|
@ -106,7 +103,7 @@ verifyActiveIdentity context session lane ipA ipB directory = do
|
||||||
"first-epoch"
|
"first-epoch"
|
||||||
assertContains "active replay after terminal window"
|
assertContains "active replay after terminal window"
|
||||||
"\"result\":\"pending\""
|
"\"result\":\"pending\""
|
||||||
(curlBody pending)
|
(probeBody pending)
|
||||||
locked <- targetedUploadAt
|
locked <- targetedUploadAt
|
||||||
context
|
context
|
||||||
session
|
session
|
||||||
|
|
@ -115,14 +112,14 @@ verifyActiveIdentity context session lane ipA ipB directory = do
|
||||||
"active-window.bin"
|
"active-window.bin"
|
||||||
"73000000-0000-4000-8000-000000000003"
|
"73000000-0000-4000-8000-000000000003"
|
||||||
"first-epoch"
|
"first-epoch"
|
||||||
assertEqual "active key lock status" 200 (curlStatus locked)
|
assertEqual "active key lock status" 200 (probeStatus locked)
|
||||||
assertContains "active key lock"
|
assertContains "active key lock"
|
||||||
"\"result\":\"rejected\""
|
"\"result\":\"rejected\""
|
||||||
(curlBody locked)
|
(probeBody locked)
|
||||||
completed <- wait active
|
completed <- wait active
|
||||||
assertContains "active request completion"
|
assertContains "active request completion"
|
||||||
"\"result\":\"succeeded\""
|
"\"result\":\"succeeded\""
|
||||||
(curlBody completed)
|
(probeBody completed)
|
||||||
|
|
||||||
targetedUpload
|
targetedUpload
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
|
|
@ -131,7 +128,7 @@ targetedUpload
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
targetedUpload context session lane ip =
|
targetedUpload context session lane ip =
|
||||||
targetedUploadAt context session lane ip "idempotency.txt"
|
targetedUploadAt context session lane ip "idempotency.txt"
|
||||||
|
|
||||||
|
|
@ -143,15 +140,39 @@ targetedUploadAt
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
targetedUploadAt context session lane ip path operationId =
|
targetedUploadAt context session lane ip path operationId body =
|
||||||
uploadTo
|
uploadDeclared
|
||||||
(probeBase context)
|
context
|
||||||
(resolveArgs lane ip)
|
|
||||||
session
|
session
|
||||||
"personal"
|
lane
|
||||||
|
ip
|
||||||
path
|
path
|
||||||
operationId
|
operationId
|
||||||
|
(BS.length body)
|
||||||
|
(sha256Hex body)
|
||||||
|
body
|
||||||
|
|
||||||
|
targetedDownload
|
||||||
|
:: ProbeContext
|
||||||
|
-> Session
|
||||||
|
-> String
|
||||||
|
-> String
|
||||||
|
-> String
|
||||||
|
-> String
|
||||||
|
-> IO ProbeResponse
|
||||||
|
targetedDownload context session lane ip root path = do
|
||||||
|
cookies <- cookieHeader (sessionCookies session)
|
||||||
|
curl
|
||||||
|
( resolveArgs lane ip
|
||||||
|
<> [ "--header", "Cookie: " <> B8.unpack cookies
|
||||||
|
, probeBase context
|
||||||
|
<> "/api/download?root="
|
||||||
|
<> root
|
||||||
|
<> "&path="
|
||||||
|
<> path
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
uploadDeclared
|
uploadDeclared
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
|
|
@ -163,14 +184,15 @@ uploadDeclared
|
||||||
-> Int
|
-> Int
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
uploadDeclared context session lane ip path operationId size digest body =
|
uploadDeclared context session lane ip path operationId size digest body =
|
||||||
withSystemTempFile "nuthatch-declared-upload" $ \bodyPath handle -> do
|
withSystemTempFile "nuthatch-declared-upload" $ \bodyPath handle -> do
|
||||||
BS.hPut handle body
|
BS.hPut handle body
|
||||||
hClose handle
|
hClose handle
|
||||||
|
cookies <- cookieHeader (sessionCookies session)
|
||||||
curl
|
curl
|
||||||
( resolveArgs lane ip
|
( resolveArgs lane ip
|
||||||
<> [ "--cookie", sessionCookies session
|
<> [ "--header", "Cookie: " <> B8.unpack cookies
|
||||||
, "--header", "Content-Type: application/octet-stream"
|
, "--header", "Content-Type: application/octet-stream"
|
||||||
, "--header", "X-Declared-Size: " <> show size
|
, "--header", "X-Declared-Size: " <> show size
|
||||||
, "--header", "X-Content-SHA256: " <> B8.unpack digest
|
, "--header", "X-Content-SHA256: " <> B8.unpack digest
|
||||||
|
|
@ -193,8 +215,9 @@ slowUpload
|
||||||
-> FilePath
|
-> FilePath
|
||||||
-> String
|
-> String
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
slowUpload context session lane ip directory operationId body = do
|
slowUpload context session lane ip directory operationId body = do
|
||||||
|
cookies <- cookieHeader (sessionCookies session)
|
||||||
let outputPath = directory <> "/active-upload-response"
|
let outputPath = directory <> "/active-upload-response"
|
||||||
statusPath = directory <> "/active-upload-status"
|
statusPath = directory <> "/active-upload-status"
|
||||||
firstChunk = BS.take 65536 body
|
firstChunk = BS.take 65536 body
|
||||||
|
|
@ -204,7 +227,7 @@ slowUpload context session lane ip directory operationId body = do
|
||||||
, "--silent"
|
, "--silent"
|
||||||
, "--show-error"
|
, "--show-error"
|
||||||
, "--resolve", "nuthatch-" <> lane <> ":8080:" <> ip
|
, "--resolve", "nuthatch-" <> lane <> ":8080:" <> ip
|
||||||
, "--cookie", sessionCookies session
|
, "--header", "Cookie: " <> B8.unpack cookies
|
||||||
, "--header", "Content-Type: application/octet-stream"
|
, "--header", "Content-Type: application/octet-stream"
|
||||||
, "--header", "X-Declared-Size: " <> show (BS.length body)
|
, "--header", "X-Declared-Size: " <> show (BS.length body)
|
||||||
, "--header", "X-Content-SHA256: " <> B8.unpack (sha256Hex body)
|
, "--header", "X-Content-SHA256: " <> B8.unpack (sha256Hex body)
|
||||||
|
|
@ -240,10 +263,10 @@ slowUpload context session lane ip directory operationId body = do
|
||||||
status <- case reads (B8.unpack statusBytes) of
|
status <- case reads (B8.unpack statusBytes) of
|
||||||
[(value, "")] -> pure value
|
[(value, "")] -> pure value
|
||||||
_ -> fail "slow upload returned invalid HTTP status"
|
_ -> fail "slow upload returned invalid HTTP status"
|
||||||
pure CurlResponse
|
pure ProbeResponse
|
||||||
{ curlStatus = status
|
{ probeStatus = status
|
||||||
, curlHeaders = []
|
, probeHeaders = []
|
||||||
, curlBody = responseBody
|
, probeBody = responseBody
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveArgs :: String -> String -> [String]
|
resolveArgs :: String -> String -> [String]
|
||||||
|
|
@ -255,13 +278,13 @@ resolveArgs lane ip =
|
||||||
, "--resolve", "nuthatch-" <> lane <> ":8080:" <> ip
|
, "--resolve", "nuthatch-" <> lane <> ":8080:" <> ip
|
||||||
]
|
]
|
||||||
|
|
||||||
requireSucceededWindow :: String -> CurlResponse -> IO ()
|
requireSucceededWindow :: String -> ProbeResponse -> IO ()
|
||||||
requireSucceededWindow label response = do
|
requireSucceededWindow label response = do
|
||||||
assertEqual (label <> " status") 200 (curlStatus response)
|
assertEqual (label <> " status") 200 (probeStatus response)
|
||||||
assertContains label "\"result\":\"succeeded\"" (curlBody response)
|
assertContains label "\"result\":\"succeeded\"" (probeBody response)
|
||||||
assertContains (label <> " window")
|
assertContains (label <> " window")
|
||||||
"\"idempotency_window_seconds\":2"
|
"\"idempotency_window_seconds\":2"
|
||||||
(curlBody response)
|
(probeBody response)
|
||||||
|
|
||||||
emitRefinementTrace :: IO ()
|
emitRefinementTrace :: IO ()
|
||||||
emitRefinementTrace =
|
emitRefinementTrace =
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,21 @@
|
||||||
module Nuthatch.Probe.Curl
|
module Nuthatch.Probe.Curl
|
||||||
( CurlResponse (..)
|
( CurlTransportFailure (..)
|
||||||
, CurlTransportFailure (..)
|
|
||||||
, cookieValue
|
|
||||||
, curl
|
, curl
|
||||||
, curlToFile
|
, curlToFile
|
||||||
, headerValue
|
|
||||||
, headerValues
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Exception (Exception, throwIO)
|
import Control.Exception (Exception, throwIO)
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Char8 as B8
|
import qualified Data.ByteString.Char8 as B8
|
||||||
import Data.Char (toLower)
|
import qualified Data.CaseInsensitive as CI
|
||||||
import Data.List (find)
|
|
||||||
import Data.Maybe (mapMaybe)
|
import Data.Maybe (mapMaybe)
|
||||||
|
import Network.HTTP.Types (Header)
|
||||||
|
import Nuthatch.Probe.Http (ProbeResponse (..))
|
||||||
import System.Exit (ExitCode (..))
|
import System.Exit (ExitCode (..))
|
||||||
import System.IO.Temp (withSystemTempDirectory)
|
import System.IO.Temp (withSystemTempDirectory)
|
||||||
import System.Process (readProcessWithExitCode)
|
import System.Process (readProcessWithExitCode)
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
data CurlResponse = CurlResponse
|
|
||||||
{ curlStatus :: Int
|
|
||||||
, curlHeaders :: [(BS.ByteString, BS.ByteString)]
|
|
||||||
, curlBody :: BS.ByteString
|
|
||||||
} deriving stock (Eq, Show)
|
|
||||||
|
|
||||||
data CurlTransportFailure = CurlTransportFailure
|
data CurlTransportFailure = CurlTransportFailure
|
||||||
{ curlTransportExitCode :: Int
|
{ curlTransportExitCode :: Int
|
||||||
, curlTransportStderr :: String
|
, curlTransportStderr :: String
|
||||||
|
|
@ -32,19 +23,19 @@ data CurlTransportFailure = CurlTransportFailure
|
||||||
|
|
||||||
instance Exception CurlTransportFailure
|
instance Exception CurlTransportFailure
|
||||||
|
|
||||||
curl :: [String] -> IO CurlResponse
|
curl :: [String] -> IO ProbeResponse
|
||||||
curl arguments =
|
curl arguments =
|
||||||
withSystemTempDirectory "nuthatch-curl" $ \directory -> do
|
withSystemTempDirectory "nuthatch-curl" $ \directory -> do
|
||||||
let bodyPath = directory <> "/body"
|
let bodyPath = directory <> "/body"
|
||||||
response <- runCurl bodyPath arguments
|
response <- runCurl bodyPath arguments
|
||||||
body <- BS.readFile bodyPath
|
body <- BS.readFile bodyPath
|
||||||
pure response { curlBody = body }
|
pure response { probeBody = body }
|
||||||
|
|
||||||
curlToFile :: FilePath -> [String] -> IO CurlResponse
|
curlToFile :: FilePath -> [String] -> IO ProbeResponse
|
||||||
curlToFile outputPath arguments =
|
curlToFile outputPath arguments =
|
||||||
runCurl outputPath arguments
|
runCurl outputPath arguments
|
||||||
|
|
||||||
runCurl :: FilePath -> [String] -> IO CurlResponse
|
runCurl :: FilePath -> [String] -> IO ProbeResponse
|
||||||
runCurl outputPath arguments =
|
runCurl outputPath arguments =
|
||||||
withSystemTempDirectory "nuthatch-curl-headers" $ \directory -> do
|
withSystemTempDirectory "nuthatch-curl-headers" $ \directory -> do
|
||||||
let headersPath = directory <> "/headers"
|
let headersPath = directory <> "/headers"
|
||||||
|
|
@ -70,13 +61,13 @@ runCurl outputPath arguments =
|
||||||
, curlTransportStderr = stderrText
|
, curlTransportStderr = stderrText
|
||||||
}
|
}
|
||||||
headers <- parseHeaders <$> BS.readFile headersPath
|
headers <- parseHeaders <$> BS.readFile headersPath
|
||||||
pure CurlResponse
|
pure ProbeResponse
|
||||||
{ curlStatus = status
|
{ probeStatus = status
|
||||||
, curlHeaders = headers
|
, probeHeaders = headers
|
||||||
, curlBody = BS.empty
|
, probeBody = BS.empty
|
||||||
}
|
}
|
||||||
|
|
||||||
parseHeaders :: BS.ByteString -> [(BS.ByteString, BS.ByteString)]
|
parseHeaders :: BS.ByteString -> [Header]
|
||||||
parseHeaders input =
|
parseHeaders input =
|
||||||
case filter (not . null) (splitBlocks (B8.lines input)) of
|
case filter (not . null) (splitBlocks (B8.lines input)) of
|
||||||
[] -> []
|
[] -> []
|
||||||
|
|
@ -88,45 +79,15 @@ parseHeaders input =
|
||||||
| otherwise = (stripCarriageReturn line : block) : blocks
|
| otherwise = (stripCarriageReturn line : block) : blocks
|
||||||
splitLine _ [] = []
|
splitLine _ [] = []
|
||||||
|
|
||||||
parseHeader :: BS.ByteString -> Maybe (BS.ByteString, BS.ByteString)
|
parseHeader :: BS.ByteString -> Maybe Header
|
||||||
parseHeader line =
|
parseHeader line =
|
||||||
case B8.break (== ':') line of
|
case B8.break (== ':') line of
|
||||||
(_, remainder) | BS.null remainder -> Nothing
|
(_, remainder) | BS.null remainder -> Nothing
|
||||||
(name, remainder) ->
|
(name, remainder) ->
|
||||||
Just (lower name, B8.dropWhile (== ' ') (BS.drop 1 remainder))
|
Just (CI.mk name, B8.dropWhile (== ' ') (BS.drop 1 remainder))
|
||||||
|
|
||||||
headerValue :: BS.ByteString -> CurlResponse -> Maybe BS.ByteString
|
|
||||||
headerValue name response =
|
|
||||||
snd <$> find ((== lower name) . fst) (curlHeaders response)
|
|
||||||
|
|
||||||
headerValues :: BS.ByteString -> CurlResponse -> [BS.ByteString]
|
|
||||||
headerValues name response =
|
|
||||||
map snd (filter ((== lower name) . fst) (curlHeaders response))
|
|
||||||
|
|
||||||
lower :: BS.ByteString -> BS.ByteString
|
|
||||||
lower = B8.map toLower
|
|
||||||
|
|
||||||
stripCarriageReturn :: BS.ByteString -> BS.ByteString
|
stripCarriageReturn :: BS.ByteString -> BS.ByteString
|
||||||
stripCarriageReturn value =
|
stripCarriageReturn value =
|
||||||
case B8.unsnoc value of
|
case B8.unsnoc value of
|
||||||
Just (prefix, '\r') -> prefix
|
Just (prefix, '\r') -> prefix
|
||||||
_ -> value
|
_ -> value
|
||||||
|
|
||||||
cookieValue :: FilePath -> BS.ByteString -> IO BS.ByteString
|
|
||||||
cookieValue path expectedName = do
|
|
||||||
contents <- B8.lines <$> BS.readFile path
|
|
||||||
case mapMaybe parseCookie contents of
|
|
||||||
cookies ->
|
|
||||||
maybe
|
|
||||||
(fail ("cookie not found: " <> B8.unpack expectedName))
|
|
||||||
pure
|
|
||||||
(lookup expectedName cookies)
|
|
||||||
|
|
||||||
parseCookie :: BS.ByteString -> Maybe (BS.ByteString, BS.ByteString)
|
|
||||||
parseCookie line =
|
|
||||||
let normalized =
|
|
||||||
maybe line id (BS.stripPrefix "#HttpOnly_" line)
|
|
||||||
fields = B8.split '\t' normalized
|
|
||||||
in case drop 5 fields of
|
|
||||||
name : value : _ -> Just (name, value)
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
|
||||||
258
test/Nuthatch/Probe/Http.hs
Normal file
258
test/Nuthatch/Probe/Http.hs
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
module Nuthatch.Probe.Http
|
||||||
|
( CookieSession
|
||||||
|
, HttpException
|
||||||
|
, ProbeClient
|
||||||
|
, ProbeResponse (..)
|
||||||
|
, RequestBody (..)
|
||||||
|
, RequestOptions (..)
|
||||||
|
, cookieHeader
|
||||||
|
, cookieValue
|
||||||
|
, defaultRequestOptions
|
||||||
|
, downloadTo
|
||||||
|
, get
|
||||||
|
, getWith
|
||||||
|
, headWith
|
||||||
|
, headerValue
|
||||||
|
, headerValues
|
||||||
|
, isConnectionFailure
|
||||||
|
, newCookieSession
|
||||||
|
, newProbeClient
|
||||||
|
, postWith
|
||||||
|
, putWith
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Control.Concurrent.MVar
|
||||||
|
import qualified Data.ByteString as BS
|
||||||
|
import qualified Data.ByteString.Char8 as B8
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
import Data.List (find)
|
||||||
|
import qualified Data.CaseInsensitive as CI
|
||||||
|
import Network.Connection (TLSSettings (..))
|
||||||
|
import Network.HTTP.Client
|
||||||
|
( Cookie (..)
|
||||||
|
, CookieJar
|
||||||
|
, HttpException (..)
|
||||||
|
, HttpExceptionContent (..)
|
||||||
|
, Manager
|
||||||
|
, Request
|
||||||
|
, Response
|
||||||
|
, brRead
|
||||||
|
, applyBasicAuth
|
||||||
|
, checkResponse
|
||||||
|
, cookieJar
|
||||||
|
, createCookieJar
|
||||||
|
, destroyCookieJar
|
||||||
|
, httpLbs
|
||||||
|
, method
|
||||||
|
, parseRequest
|
||||||
|
, redirectCount
|
||||||
|
, requestBody
|
||||||
|
, requestHeaders
|
||||||
|
, responseBody
|
||||||
|
, responseCookieJar
|
||||||
|
, responseHeaders
|
||||||
|
, responseStatus
|
||||||
|
, responseTimeout
|
||||||
|
, responseTimeoutMicro
|
||||||
|
, withResponse
|
||||||
|
)
|
||||||
|
import qualified Network.HTTP.Client as HTTP
|
||||||
|
import Network.HTTP.Client.TLS (mkManagerSettings)
|
||||||
|
import Network.HTTP.Types
|
||||||
|
( Header
|
||||||
|
, Method
|
||||||
|
, hContentType
|
||||||
|
, methodGet
|
||||||
|
, methodHead
|
||||||
|
, methodPost
|
||||||
|
, methodPut
|
||||||
|
, statusCode
|
||||||
|
)
|
||||||
|
import Network.HTTP.Types.URI (renderSimpleQuery)
|
||||||
|
import Network.TLS (defaultSupported)
|
||||||
|
import System.IO (IOMode (WriteMode), withBinaryFile)
|
||||||
|
|
||||||
|
newtype ProbeClient = ProbeClient Manager
|
||||||
|
|
||||||
|
newtype CookieSession = CookieSession (MVar CookieJar)
|
||||||
|
|
||||||
|
data ProbeResponse = ProbeResponse
|
||||||
|
{ probeStatus :: Int
|
||||||
|
, probeHeaders :: [Header]
|
||||||
|
, probeBody :: BS.ByteString
|
||||||
|
}
|
||||||
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
|
data RequestBody
|
||||||
|
= NoBody
|
||||||
|
| ByteBody BS.ByteString
|
||||||
|
| FileBody FilePath
|
||||||
|
| FormBody [(BS.ByteString, BS.ByteString)]
|
||||||
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
|
data RequestOptions = RequestOptions
|
||||||
|
{ optionHeaders :: [Header]
|
||||||
|
, optionBody :: RequestBody
|
||||||
|
, optionCookies :: Maybe CookieSession
|
||||||
|
, optionBasicAuth :: Maybe (BS.ByteString, BS.ByteString)
|
||||||
|
, optionQuery :: [(BS.ByteString, Maybe BS.ByteString)]
|
||||||
|
, optionTimeoutMicros :: Maybe Int
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRequestOptions :: RequestOptions
|
||||||
|
defaultRequestOptions = RequestOptions
|
||||||
|
{ optionHeaders = []
|
||||||
|
, optionBody = NoBody
|
||||||
|
, optionCookies = Nothing
|
||||||
|
, optionBasicAuth = Nothing
|
||||||
|
, optionQuery = []
|
||||||
|
, optionTimeoutMicros = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
newProbeClient :: IO ProbeClient
|
||||||
|
newProbeClient =
|
||||||
|
ProbeClient <$> HTTP.newManager
|
||||||
|
(mkManagerSettings
|
||||||
|
TLSSettingsSimple
|
||||||
|
{ settingDisableCertificateValidation = True
|
||||||
|
, settingDisableSession = False
|
||||||
|
, settingUseServerName = True
|
||||||
|
, settingClientSupported = defaultSupported
|
||||||
|
}
|
||||||
|
Nothing)
|
||||||
|
|
||||||
|
newCookieSession :: IO CookieSession
|
||||||
|
newCookieSession = CookieSession <$> newMVar (createCookieJar [])
|
||||||
|
|
||||||
|
get :: ProbeClient -> String -> IO ProbeResponse
|
||||||
|
get client = getWith client defaultRequestOptions
|
||||||
|
|
||||||
|
getWith :: ProbeClient -> RequestOptions -> String -> IO ProbeResponse
|
||||||
|
getWith client = perform client methodGet
|
||||||
|
|
||||||
|
postWith :: ProbeClient -> RequestOptions -> String -> IO ProbeResponse
|
||||||
|
postWith client = perform client methodPost
|
||||||
|
|
||||||
|
putWith :: ProbeClient -> RequestOptions -> String -> IO ProbeResponse
|
||||||
|
putWith client = perform client methodPut
|
||||||
|
|
||||||
|
headWith :: ProbeClient -> RequestOptions -> String -> IO ProbeResponse
|
||||||
|
headWith client = perform client methodHead
|
||||||
|
|
||||||
|
downloadTo
|
||||||
|
:: ProbeClient
|
||||||
|
-> RequestOptions
|
||||||
|
-> FilePath
|
||||||
|
-> String
|
||||||
|
-> IO ProbeResponse
|
||||||
|
downloadTo (ProbeClient manager) options outputPath url = do
|
||||||
|
request <- buildRequest methodGet options url
|
||||||
|
withResponse request manager $ \response -> do
|
||||||
|
updateCookies options response
|
||||||
|
withBinaryFile outputPath WriteMode $ \handle ->
|
||||||
|
drain handle (responseBody response)
|
||||||
|
pure (responseWithoutBody response)
|
||||||
|
where
|
||||||
|
drain handle reader = do
|
||||||
|
chunk <- brRead reader
|
||||||
|
if BS.null chunk
|
||||||
|
then pure ()
|
||||||
|
else BS.hPut handle chunk >> drain handle reader
|
||||||
|
|
||||||
|
perform
|
||||||
|
:: ProbeClient
|
||||||
|
-> Method
|
||||||
|
-> RequestOptions
|
||||||
|
-> String
|
||||||
|
-> IO ProbeResponse
|
||||||
|
perform (ProbeClient manager) requestMethod options url = do
|
||||||
|
request <- buildRequest requestMethod options url
|
||||||
|
response <- httpLbs request manager
|
||||||
|
updateCookies options response
|
||||||
|
pure ProbeResponse
|
||||||
|
{ probeStatus = statusCode (responseStatus response)
|
||||||
|
, probeHeaders = responseHeaders response
|
||||||
|
, probeBody = BL.toStrict (responseBody response)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildRequest :: Method -> RequestOptions -> String -> IO Request
|
||||||
|
buildRequest requestMethod options url = do
|
||||||
|
initial <- parseRequest url
|
||||||
|
body <- toRequestBody (optionBody options)
|
||||||
|
storedCookies <- traverse readCookies (optionCookies options)
|
||||||
|
let formHeaders =
|
||||||
|
case optionBody options of
|
||||||
|
FormBody _ -> [(hContentType, "application/x-www-form-urlencoded")]
|
||||||
|
_ -> []
|
||||||
|
queried =
|
||||||
|
if null (optionQuery options)
|
||||||
|
then initial
|
||||||
|
else HTTP.setQueryString (optionQuery options) initial
|
||||||
|
let configured =
|
||||||
|
queried
|
||||||
|
{ method = requestMethod
|
||||||
|
, requestHeaders = formHeaders <> optionHeaders options
|
||||||
|
, requestBody = body
|
||||||
|
, cookieJar = storedCookies
|
||||||
|
, redirectCount = 0
|
||||||
|
, checkResponse = \_ _ -> pure ()
|
||||||
|
, responseTimeout =
|
||||||
|
maybe
|
||||||
|
(responseTimeout initial)
|
||||||
|
responseTimeoutMicro
|
||||||
|
(optionTimeoutMicros options)
|
||||||
|
}
|
||||||
|
pure $ maybe configured
|
||||||
|
(\(username, password) -> applyBasicAuth username password configured)
|
||||||
|
(optionBasicAuth options)
|
||||||
|
|
||||||
|
toRequestBody :: RequestBody -> IO HTTP.RequestBody
|
||||||
|
toRequestBody NoBody = pure (HTTP.RequestBodyBS BS.empty)
|
||||||
|
toRequestBody (ByteBody body) = pure (HTTP.RequestBodyBS body)
|
||||||
|
toRequestBody (FileBody path) = HTTP.streamFile path
|
||||||
|
toRequestBody (FormBody fields) =
|
||||||
|
pure . HTTP.RequestBodyBS $ renderSimpleQuery False fields
|
||||||
|
|
||||||
|
updateCookies :: RequestOptions -> Response body -> IO ()
|
||||||
|
updateCookies options response =
|
||||||
|
case optionCookies options of
|
||||||
|
Nothing -> pure ()
|
||||||
|
Just (CookieSession storage) -> do
|
||||||
|
_ <- swapMVar storage (responseCookieJar response)
|
||||||
|
pure ()
|
||||||
|
|
||||||
|
readCookies :: CookieSession -> IO CookieJar
|
||||||
|
readCookies (CookieSession storage) = readMVar storage
|
||||||
|
|
||||||
|
cookieValue :: CookieSession -> BS.ByteString -> IO BS.ByteString
|
||||||
|
cookieValue session expectedName = do
|
||||||
|
cookies <- destroyCookieJar <$> readCookies session
|
||||||
|
maybe
|
||||||
|
(fail ("cookie not found: " <> B8.unpack expectedName))
|
||||||
|
(pure . cookie_value)
|
||||||
|
(find ((== expectedName) . cookie_name) cookies)
|
||||||
|
|
||||||
|
cookieHeader :: CookieSession -> IO BS.ByteString
|
||||||
|
cookieHeader session = do
|
||||||
|
cookies <- destroyCookieJar <$> readCookies session
|
||||||
|
pure . B8.intercalate "; " $
|
||||||
|
[cookie_name cookie <> "=" <> cookie_value cookie | cookie <- cookies]
|
||||||
|
|
||||||
|
headerValue :: BS.ByteString -> ProbeResponse -> Maybe BS.ByteString
|
||||||
|
headerValue name response =
|
||||||
|
snd <$> find ((== CI.mk name) . fst) (probeHeaders response)
|
||||||
|
|
||||||
|
headerValues :: BS.ByteString -> ProbeResponse -> [BS.ByteString]
|
||||||
|
headerValues name response =
|
||||||
|
map snd (filter ((== CI.mk name) . fst) (probeHeaders response))
|
||||||
|
|
||||||
|
responseWithoutBody :: Response body -> ProbeResponse
|
||||||
|
responseWithoutBody response = ProbeResponse
|
||||||
|
{ probeStatus = statusCode (responseStatus response)
|
||||||
|
, probeHeaders = responseHeaders response
|
||||||
|
, probeBody = BS.empty
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnectionFailure :: HttpException -> Bool
|
||||||
|
isConnectionFailure (HttpExceptionRequest _ (ConnectionFailure _)) = True
|
||||||
|
isConnectionFailure _ = False
|
||||||
|
|
@ -20,13 +20,12 @@ import Data.Char (toLower)
|
||||||
import Data.List (find)
|
import Data.List (find)
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
import Nuthatch.Probe.Assert
|
import Nuthatch.Probe.Assert
|
||||||
import Nuthatch.Probe.Curl
|
import Nuthatch.Probe.Http
|
||||||
import System.Environment (lookupEnv)
|
import System.Environment (lookupEnv)
|
||||||
import System.FilePath ((</>))
|
|
||||||
|
|
||||||
data ProbeContext = ProbeContext
|
data ProbeContext = ProbeContext
|
||||||
{ probeBase :: String
|
{ probeClient :: ProbeClient
|
||||||
, probeDirectory :: FilePath
|
, probeBase :: String
|
||||||
, probeOidcIssuer :: String
|
, probeOidcIssuer :: String
|
||||||
, probeOidcProvider :: String
|
, probeOidcProvider :: String
|
||||||
, probeOidcPassword :: String
|
, probeOidcPassword :: String
|
||||||
|
|
@ -37,7 +36,7 @@ data ProbeContext = ProbeContext
|
||||||
}
|
}
|
||||||
|
|
||||||
data Session = Session
|
data Session = Session
|
||||||
{ sessionCookies :: FilePath
|
{ sessionCookies :: CookieSession
|
||||||
, sessionCsrf :: BS.ByteString
|
, sessionCsrf :: BS.ByteString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,8 +63,9 @@ instance FromJSON OidcDiscovery where
|
||||||
parseJSON = withObject "OidcDiscovery" $ \object ->
|
parseJSON = withObject "OidcDiscovery" $ \object ->
|
||||||
OidcDiscovery <$> object .: "token_endpoint"
|
OidcDiscovery <$> object .: "token_endpoint"
|
||||||
|
|
||||||
newProbeContext :: FilePath -> IO ProbeContext
|
newProbeContext :: IO ProbeContext
|
||||||
newProbeContext directory = do
|
newProbeContext = do
|
||||||
|
client <- newProbeClient
|
||||||
lane <- requireEnv "NUTHATCH_FIXTURE_LANE"
|
lane <- requireEnv "NUTHATCH_FIXTURE_LANE"
|
||||||
issuer <- requireEnv "NUTHATCH_OIDC_ISSUER"
|
issuer <- requireEnv "NUTHATCH_OIDC_ISSUER"
|
||||||
provider <- requireEnv "NUTHATCH_OIDC_PROVIDER"
|
provider <- requireEnv "NUTHATCH_OIDC_PROVIDER"
|
||||||
|
|
@ -75,8 +75,8 @@ newProbeContext directory = do
|
||||||
clientSecret <- requireEnv "NUTHATCH_OIDC_CLIENT_SECRET"
|
clientSecret <- requireEnv "NUTHATCH_OIDC_CLIENT_SECRET"
|
||||||
redirectUri <- requireEnv "NUTHATCH_OIDC_REDIRECT_URI"
|
redirectUri <- requireEnv "NUTHATCH_OIDC_REDIRECT_URI"
|
||||||
pure ProbeContext
|
pure ProbeContext
|
||||||
{ probeBase = "https://nuthatch-" <> lane <> ":8080"
|
{ probeClient = client
|
||||||
, probeDirectory = directory
|
, probeBase = "https://nuthatch-" <> lane <> ":8080"
|
||||||
, probeOidcIssuer = issuer
|
, probeOidcIssuer = issuer
|
||||||
, probeOidcProvider = provider
|
, probeOidcProvider = provider
|
||||||
, probeOidcPassword = password
|
, probeOidcPassword = password
|
||||||
|
|
@ -88,15 +88,15 @@ newProbeContext directory = do
|
||||||
|
|
||||||
login :: ProbeContext -> Bool -> String -> String -> IO Session
|
login :: ProbeContext -> Bool -> String -> String -> IO Session
|
||||||
login context verifyBoundary user suffix = do
|
login context verifyBoundary user suffix = do
|
||||||
let cookies = probeDirectory context </> "cookies-" <> suffix
|
cookies <- newCookieSession
|
||||||
idpCookies = probeDirectory context </> "idp-" <> suffix
|
idpCookies <- newCookieSession
|
||||||
started <-
|
started <-
|
||||||
retryTransport 20 $
|
retryTransport 20 $
|
||||||
curl
|
getWith
|
||||||
[ "--cookie-jar", cookies
|
(probeClient context)
|
||||||
, probeBase context <> "/login"
|
defaultRequestOptions { optionCookies = Just cookies }
|
||||||
]
|
(probeBase context <> "/login")
|
||||||
assertEqual "login redirect status" 303 (curlStatus started)
|
assertEqual "login redirect status" 303 (probeStatus started)
|
||||||
authorizationUrl <-
|
authorizationUrl <-
|
||||||
maybe (fail "login response omitted Location") (pure . B8.unpack) $
|
maybe (fail "login response omitted Location") (pure . B8.unpack) $
|
||||||
headerValue "location" started
|
headerValue "location" started
|
||||||
|
|
@ -109,12 +109,11 @@ login context verifyBoundary user suffix = do
|
||||||
then verifyTransactionBoundary context cookies authorizationUrl state
|
then verifyTransactionBoundary context cookies authorizationUrl state
|
||||||
else pure ()
|
else pure ()
|
||||||
callback <- completeAuthorization context idpCookies user suffix authorizationUrl
|
callback <- completeAuthorization context idpCookies user suffix authorizationUrl
|
||||||
completed <- curl
|
completed <- getWith
|
||||||
[ "--cookie", cookies
|
(probeClient context)
|
||||||
, "--cookie-jar", cookies
|
defaultRequestOptions { optionCookies = Just cookies }
|
||||||
, callback
|
callback
|
||||||
]
|
assertEqual "OIDC callback status" 303 (probeStatus completed)
|
||||||
assertEqual "OIDC callback status" 303 (curlStatus completed)
|
|
||||||
assertEqual "OIDC callback location"
|
assertEqual "OIDC callback location"
|
||||||
(Just "/app")
|
(Just "/app")
|
||||||
(B8.unpack <$> headerValue "location" completed)
|
(B8.unpack <$> headerValue "location" completed)
|
||||||
|
|
@ -143,12 +142,12 @@ login context verifyBoundary user suffix = do
|
||||||
|
|
||||||
verifyPkceBoundary :: ProbeContext -> String -> IO ()
|
verifyPkceBoundary :: ProbeContext -> String -> IO ()
|
||||||
verifyPkceBoundary context user = do
|
verifyPkceBoundary context user = do
|
||||||
let cookies = probeDirectory context </> "cookies-pkce"
|
cookies <- newCookieSession
|
||||||
idpCookies = probeDirectory context </> "idp-pkce"
|
idpCookies <- newCookieSession
|
||||||
started <- curl
|
started <- getWith
|
||||||
[ "--cookie-jar", cookies
|
(probeClient context)
|
||||||
, probeBase context <> "/login"
|
defaultRequestOptions { optionCookies = Just cookies }
|
||||||
]
|
(probeBase context <> "/login")
|
||||||
authorizationUrl <-
|
authorizationUrl <-
|
||||||
maybe (fail "PKCE login omitted Location") (pure . B8.unpack) $
|
maybe (fail "PKCE login omitted Location") (pure . B8.unpack) $
|
||||||
headerValue "location" started
|
headerValue "location" started
|
||||||
|
|
@ -159,62 +158,72 @@ verifyPkceBoundary context user = do
|
||||||
"pkce"
|
"pkce"
|
||||||
authorizationUrl
|
authorizationUrl
|
||||||
code <- queryValue "code" callback
|
code <- queryValue "code" callback
|
||||||
discoveryResponse <- curl
|
discoveryResponse <- get
|
||||||
[probeOidcIssuer context <> "/.well-known/openid-configuration"]
|
(probeClient context)
|
||||||
assertEqual "OIDC discovery status" 200 (curlStatus discoveryResponse)
|
(probeOidcIssuer context <> "/.well-known/openid-configuration")
|
||||||
|
assertEqual "OIDC discovery status" 200 (probeStatus discoveryResponse)
|
||||||
OidcDiscovery tokenEndpoint <-
|
OidcDiscovery tokenEndpoint <-
|
||||||
decodeBody "OIDC discovery" discoveryResponse
|
decodeBody "OIDC discovery" discoveryResponse
|
||||||
rejected <- curl
|
rejected <- postWith
|
||||||
[ "--user"
|
(probeClient context)
|
||||||
, probeOidcClientId context <> ":" <> probeOidcClientSecret context
|
defaultRequestOptions
|
||||||
, "--data-urlencode", "grant_type=authorization_code"
|
{ optionBasicAuth = Just
|
||||||
, "--data-urlencode", "code=" <> code
|
( B8.pack (probeOidcClientId context)
|
||||||
, "--data-urlencode", "redirect_uri=" <> probeOidcRedirectUri context
|
, B8.pack (probeOidcClientSecret context)
|
||||||
, "--data-urlencode"
|
)
|
||||||
, "code_verifier=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
, optionBody = FormBody
|
||||||
, tokenEndpoint
|
[ ("grant_type", "authorization_code")
|
||||||
]
|
, ("code", B8.pack code)
|
||||||
|
, ("redirect_uri", B8.pack (probeOidcRedirectUri context))
|
||||||
|
, ( "code_verifier"
|
||||||
|
, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
tokenEndpoint
|
||||||
assert "provider accepted an invalid PKCE verifier" $
|
assert "provider accepted an invalid PKCE verifier" $
|
||||||
curlStatus rejected >= 400
|
probeStatus rejected >= 400
|
||||||
|
|
||||||
verifyTransactionBoundary
|
verifyTransactionBoundary
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
-> FilePath
|
-> CookieSession
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
-> IO ()
|
-> IO ()
|
||||||
verifyTransactionBoundary context cookies authorizationUrl state = do
|
verifyTransactionBoundary context cookies authorizationUrl state = do
|
||||||
wrongRedirect <- curl [replaceRedirectUri authorizationUrl]
|
wrongRedirect <- get (probeClient context) (replaceRedirectUri authorizationUrl)
|
||||||
assert "provider accepted an unregistered redirect URI" $
|
assert "provider accepted an unregistered redirect URI" $
|
||||||
curlStatus wrongRedirect >= 400
|
probeStatus wrongRedirect >= 400
|
||||||
invalidState <- curl
|
invalidState <- getWith
|
||||||
[ "--cookie", cookies
|
(probeClient context)
|
||||||
, probeBase context
|
defaultRequestOptions { optionCookies = Just cookies }
|
||||||
|
(probeBase context
|
||||||
<> "/oidc/callback?code=unused&state=invalid-"
|
<> "/oidc/callback?code=unused&state=invalid-"
|
||||||
<> state
|
<> state)
|
||||||
]
|
|
||||||
assert "application accepted an invalid OIDC state" $
|
assert "application accepted an invalid OIDC state" $
|
||||||
curlStatus invalidState >= 400
|
probeStatus invalidState >= 400
|
||||||
transaction <- cookieValue cookies "nuthatch_oidc"
|
transaction <- cookieValue cookies "nuthatch_oidc"
|
||||||
malformed <- curl
|
malformed <- getWith
|
||||||
[ "--header", "Cookie: nuthatch_oidc=malformed"
|
(probeClient context)
|
||||||
, probeBase context
|
defaultRequestOptions
|
||||||
|
{ optionHeaders = [("Cookie", "nuthatch_oidc=malformed")] }
|
||||||
|
(probeBase context
|
||||||
<> "/oidc/callback?code=unused&state="
|
<> "/oidc/callback?code=unused&state="
|
||||||
<> state
|
<> state)
|
||||||
]
|
assertEqual "malformed transaction cookie" 400 (probeStatus malformed)
|
||||||
assertEqual "malformed transaction cookie" 400 (curlStatus malformed)
|
|
||||||
let tampered = BS.cons 120 (BS.drop 1 transaction)
|
let tampered = BS.cons 120 (BS.drop 1 transaction)
|
||||||
tamperedResponse <- curl
|
tamperedResponse <- getWith
|
||||||
[ "--header", "Cookie: nuthatch_oidc=" <> B8.unpack tampered
|
(probeClient context)
|
||||||
, probeBase context
|
defaultRequestOptions
|
||||||
|
{ optionHeaders = [("Cookie", "nuthatch_oidc=" <> tampered)] }
|
||||||
|
(probeBase context
|
||||||
<> "/oidc/callback?code=unused&state="
|
<> "/oidc/callback?code=unused&state="
|
||||||
<> state
|
<> state)
|
||||||
]
|
assertEqual "tampered transaction cookie" 400 (probeStatus tamperedResponse)
|
||||||
assertEqual "tampered transaction cookie" 400 (curlStatus tamperedResponse)
|
|
||||||
|
|
||||||
completeAuthorization
|
completeAuthorization
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
-> FilePath
|
-> CookieSession
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
|
|
@ -230,25 +239,28 @@ completeAuthorization context idpCookies user suffix authorizationUrl =
|
||||||
|
|
||||||
completeKeycloak
|
completeKeycloak
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
-> FilePath
|
-> CookieSession
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
-> IO String
|
-> IO String
|
||||||
completeKeycloak context idpCookies user authorizationUrl = do
|
completeKeycloak context idpCookies user authorizationUrl = do
|
||||||
form <- curl
|
form <- getWith
|
||||||
[ "--cookie-jar", idpCookies
|
(probeClient context)
|
||||||
, authorizationUrl
|
defaultRequestOptions { optionCookies = Just idpCookies }
|
||||||
]
|
authorizationUrl
|
||||||
assertEqual "Keycloak authorization form" 200 (curlStatus form)
|
assertEqual "Keycloak authorization form" 200 (probeStatus form)
|
||||||
action <- htmlAttribute "action" (B8.unpack (curlBody form))
|
action <- htmlAttribute "action" (B8.unpack (probeBody form))
|
||||||
submitted <- curl
|
submitted <- postWith
|
||||||
[ "--cookie", idpCookies
|
(probeClient context)
|
||||||
, "--cookie-jar", idpCookies
|
defaultRequestOptions
|
||||||
, "--data-urlencode", "username=" <> user
|
{ optionCookies = Just idpCookies
|
||||||
, "--data-urlencode", "password=" <> probeOidcPassword context
|
, optionBody = FormBody
|
||||||
, "--data-urlencode", "credentialId="
|
[ ("username", B8.pack user)
|
||||||
, action
|
, ("password", B8.pack (probeOidcPassword context))
|
||||||
]
|
, ("credentialId", "")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
action
|
||||||
maybe
|
maybe
|
||||||
(fail "Keycloak login omitted callback Location")
|
(fail "Keycloak login omitted callback Location")
|
||||||
(pure . B8.unpack)
|
(pure . B8.unpack)
|
||||||
|
|
@ -263,49 +275,57 @@ completeZitadel
|
||||||
completeZitadel context user _ authorizationUrl = do
|
completeZitadel context user _ authorizationUrl = do
|
||||||
pat <- maybe (fail "ZITADEL login PAT is missing") pure $
|
pat <- maybe (fail "ZITADEL login PAT is missing") pure $
|
||||||
probeOidcLoginPat context
|
probeOidcLoginPat context
|
||||||
started <- curl [authorizationUrl]
|
started <- get (probeClient context) authorizationUrl
|
||||||
location <- maybe
|
location <- maybe
|
||||||
(fail "ZITADEL login omitted Location")
|
(fail "ZITADEL login omitted Location")
|
||||||
(pure . B8.unpack)
|
(pure . B8.unpack)
|
||||||
(headerValue "location" started)
|
(headerValue "location" started)
|
||||||
authRequest <- queryValue "authRequest" location
|
authRequest <- queryValue "authRequest" location
|
||||||
created <- curl
|
created <- postWith
|
||||||
[ "--header", "Authorization: Bearer " <> pat
|
(probeClient context)
|
||||||
, "--header", "Content-Type: application/json"
|
defaultRequestOptions
|
||||||
, "--data"
|
{ optionHeaders =
|
||||||
, "{\"checks\":{\"user\":{\"userId\":\""
|
[ ("Authorization", "Bearer " <> B8.pack pat)
|
||||||
|
, ("Content-Type", "application/json")
|
||||||
|
]
|
||||||
|
, optionBody = ByteBody . B8.pack $
|
||||||
|
"{\"checks\":{\"user\":{\"userId\":\""
|
||||||
<> user
|
<> user
|
||||||
<> "\"},\"password\":{\"password\":\""
|
<> "\"},\"password\":{\"password\":\""
|
||||||
<> probeOidcPassword context
|
<> probeOidcPassword context
|
||||||
<> "\"}},\"lifetime\":\"300s\"}"
|
<> "\"}},\"lifetime\":\"300s\"}"
|
||||||
, probeOidcIssuer context <> "/v2/sessions"
|
}
|
||||||
]
|
(probeOidcIssuer context <> "/v2/sessions")
|
||||||
assert "ZITADEL session creation failed" $
|
assert "ZITADEL session creation failed" $
|
||||||
curlStatus created >= 200 && curlStatus created < 300
|
probeStatus created >= 200 && probeStatus created < 300
|
||||||
ZitadelSession {..} <- decodeBody "ZITADEL session" created
|
ZitadelSession {..} <- decodeBody "ZITADEL session" created
|
||||||
finalized <- curl
|
finalized <- postWith
|
||||||
[ "--header", "Authorization: Bearer " <> pat
|
(probeClient context)
|
||||||
, "--header", "Content-Type: application/json"
|
defaultRequestOptions
|
||||||
, "--data"
|
{ optionHeaders =
|
||||||
, "{\"session\":{\"sessionId\":\""
|
[ ("Authorization", "Bearer " <> B8.pack pat)
|
||||||
|
, ("Content-Type", "application/json")
|
||||||
|
]
|
||||||
|
, optionBody = ByteBody . B8.pack $
|
||||||
|
"{\"session\":{\"sessionId\":\""
|
||||||
<> zitadelSessionId
|
<> zitadelSessionId
|
||||||
<> "\",\"sessionToken\":\""
|
<> "\",\"sessionToken\":\""
|
||||||
<> zitadelSessionToken
|
<> zitadelSessionToken
|
||||||
<> "\"}}"
|
<> "\"}}"
|
||||||
, probeOidcIssuer context
|
}
|
||||||
|
(probeOidcIssuer context
|
||||||
<> "/v2/oidc/auth_requests/"
|
<> "/v2/oidc/auth_requests/"
|
||||||
<> authRequest
|
<> authRequest)
|
||||||
]
|
assertEqual "ZITADEL authorization finalization" 200 (probeStatus finalized)
|
||||||
assertEqual "ZITADEL authorization finalization" 200 (curlStatus finalized)
|
|
||||||
ZitadelCallback callback <- decodeBody "ZITADEL callback" finalized
|
ZitadelCallback callback <- decodeBody "ZITADEL callback" finalized
|
||||||
pure callback
|
pure callback
|
||||||
|
|
||||||
decodeBody :: FromJSON value => String -> CurlResponse -> IO value
|
decodeBody :: FromJSON value => String -> ProbeResponse -> IO value
|
||||||
decodeBody label response =
|
decodeBody label response =
|
||||||
either (fail . ((label <> ": ") <>)) pure $
|
either (fail . ((label <> ": ") <>)) pure $
|
||||||
eitherDecodeStrict' (curlBody response)
|
eitherDecodeStrict' (probeBody response)
|
||||||
|
|
||||||
findCookie :: BS.ByteString -> CurlResponse -> BS.ByteString
|
findCookie :: BS.ByteString -> ProbeResponse -> BS.ByteString
|
||||||
findCookie name response =
|
findCookie name response =
|
||||||
fromMaybe BS.empty $
|
fromMaybe BS.empty $
|
||||||
find (containsLower (lower name <> "=")) (headerValues "set-cookie" response)
|
find (containsLower (lower name <> "=")) (headerValues "set-cookie" response)
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ module Nuthatch.Probe.ProductClient
|
||||||
( browse
|
( browse
|
||||||
, deleteObject
|
, deleteObject
|
||||||
, download
|
, download
|
||||||
, downloadFrom
|
|
||||||
, sha256Hex
|
, sha256Hex
|
||||||
, upload
|
, upload
|
||||||
, uploadTo
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Crypto.Hash (Digest, SHA256, hash)
|
import Crypto.Hash (Digest, SHA256, hash)
|
||||||
|
|
@ -16,49 +14,39 @@ import qualified Data.ByteString.Char8 as B8
|
||||||
import qualified Data.Text as Text
|
import qualified Data.Text as Text
|
||||||
import qualified Data.Text.Encoding as Text
|
import qualified Data.Text.Encoding as Text
|
||||||
import Network.HTTP.Types.URI (urlEncode)
|
import Network.HTTP.Types.URI (urlEncode)
|
||||||
import Nuthatch.Probe.Curl
|
import Nuthatch.Probe.Http
|
||||||
import Nuthatch.Probe.Oidc
|
import Nuthatch.Probe.Oidc
|
||||||
import System.IO.Temp (withSystemTempFile)
|
|
||||||
import System.IO (hClose)
|
|
||||||
|
|
||||||
browse
|
browse
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
-> Session
|
-> Session
|
||||||
-> String
|
-> String
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
browse context session query =
|
browse context session query =
|
||||||
curl
|
getWith
|
||||||
[ "--cookie", sessionCookies session
|
(probeClient context)
|
||||||
, "--header", "Accept: application/json"
|
defaultRequestOptions
|
||||||
, probeBase context <> "/api/browse" <> query
|
{ optionCookies = Just (sessionCookies session)
|
||||||
]
|
, optionHeaders = [("Accept", "application/json")]
|
||||||
|
}
|
||||||
|
(probeBase context <> "/api/browse" <> query)
|
||||||
|
|
||||||
download
|
download
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
-> Session
|
-> Session
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
download context =
|
download context session root path =
|
||||||
downloadFrom (probeBase context) []
|
getWith
|
||||||
|
(probeClient context)
|
||||||
downloadFrom
|
defaultRequestOptions
|
||||||
:: String
|
{ optionCookies = Just (sessionCookies session) }
|
||||||
-> [String]
|
( probeBase context
|
||||||
-> Session
|
<> "/api/download?root="
|
||||||
-> String
|
<> encode root
|
||||||
-> String
|
<> "&path="
|
||||||
-> IO CurlResponse
|
<> encode path
|
||||||
downloadFrom base extra session root path =
|
|
||||||
curl
|
|
||||||
( extra
|
|
||||||
<> [ "--cookie", sessionCookies session
|
|
||||||
, base
|
|
||||||
<> "/api/download?root="
|
|
||||||
<> encode root
|
|
||||||
<> "&path="
|
|
||||||
<> encode path
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
upload
|
upload
|
||||||
|
|
@ -68,41 +56,28 @@ upload
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
upload context =
|
upload context session root path operationId body =
|
||||||
uploadTo (probeBase context) []
|
postWith
|
||||||
|
(probeClient context)
|
||||||
uploadTo
|
defaultRequestOptions
|
||||||
:: String
|
{ optionCookies = Just (sessionCookies session)
|
||||||
-> [String]
|
, optionHeaders =
|
||||||
-> Session
|
[ ("Content-Type", "application/octet-stream")
|
||||||
-> String
|
, ("X-Declared-Size", B8.pack (show (BS.length body)))
|
||||||
-> String
|
, ("X-Content-SHA256", sha256Hex body)
|
||||||
-> String
|
, ("X-CSRF-Token", sessionCsrf session)
|
||||||
-> BS.ByteString
|
]
|
||||||
-> IO CurlResponse
|
, optionBody = ByteBody body
|
||||||
uploadTo base extra session root path operationId body =
|
}
|
||||||
withSystemTempFile "nuthatch-upload" $ \bodyPath handle -> do
|
( probeBase context
|
||||||
BS.hPut handle body
|
<> "/api/upload?root="
|
||||||
hClose handle
|
<> encode root
|
||||||
curl
|
<> "&path="
|
||||||
( extra
|
<> encode path
|
||||||
<> [ "--cookie", sessionCookies session
|
<> "&operation_id="
|
||||||
, "--header", "Content-Type: application/octet-stream"
|
<> encode operationId
|
||||||
, "--header", "X-Declared-Size: " <> show (BS.length body)
|
)
|
||||||
, "--header", "X-Content-SHA256: " <> B8.unpack (sha256Hex body)
|
|
||||||
, "--header"
|
|
||||||
, "X-CSRF-Token: " <> B8.unpack (sessionCsrf session)
|
|
||||||
, "--data-binary", "@" <> bodyPath
|
|
||||||
, base
|
|
||||||
<> "/api/upload?root="
|
|
||||||
<> encode root
|
|
||||||
<> "&path="
|
|
||||||
<> encode path
|
|
||||||
<> "&operation_id="
|
|
||||||
<> encode operationId
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
deleteObject
|
deleteObject
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
|
|
@ -110,13 +85,15 @@ deleteObject
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
-> String
|
-> String
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
deleteObject context session root path operationId =
|
deleteObject context session root path operationId =
|
||||||
curl
|
postWith
|
||||||
[ "--cookie", sessionCookies session
|
(probeClient context)
|
||||||
, "--header", "Content-Type: application/json"
|
defaultRequestOptions
|
||||||
, "--data"
|
{ optionCookies = Just (sessionCookies session)
|
||||||
, "{\"root\":\""
|
, optionHeaders = [("Content-Type", "application/json")]
|
||||||
|
, optionBody = ByteBody . B8.pack $
|
||||||
|
"{\"root\":\""
|
||||||
<> root
|
<> root
|
||||||
<> "\",\"path\":\""
|
<> "\",\"path\":\""
|
||||||
<> path
|
<> path
|
||||||
|
|
@ -125,8 +102,8 @@ deleteObject context session root path operationId =
|
||||||
<> "\",\"csrf\":\""
|
<> "\",\"csrf\":\""
|
||||||
<> B8.unpack (sessionCsrf session)
|
<> B8.unpack (sessionCsrf session)
|
||||||
<> "\"}"
|
<> "\"}"
|
||||||
, probeBase context <> "/api/delete"
|
}
|
||||||
]
|
(probeBase context <> "/api/delete")
|
||||||
|
|
||||||
sha256Hex :: BS.ByteString -> BS.ByteString
|
sha256Hex :: BS.ByteString -> BS.ByteString
|
||||||
sha256Hex input =
|
sha256Hex input =
|
||||||
|
|
|
||||||
|
|
@ -7,34 +7,33 @@ import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Char8 as B8
|
import qualified Data.ByteString.Char8 as B8
|
||||||
import qualified Data.Text as Text
|
import qualified Data.Text as Text
|
||||||
import Nuthatch.Probe.Assert
|
import Nuthatch.Probe.Assert
|
||||||
import Nuthatch.Probe.Curl
|
import Nuthatch.Probe.Http
|
||||||
import Nuthatch.Probe.Oidc
|
import Nuthatch.Probe.Oidc
|
||||||
import Nuthatch.Probe.ProductClient
|
import Nuthatch.Probe.ProductClient
|
||||||
import System.Environment (getArgs)
|
import System.Environment (getArgs)
|
||||||
import System.IO.Temp (withSystemTempDirectory)
|
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main =
|
main = do
|
||||||
withSystemTempDirectory "nuthatch-product-probe" $ \directory -> do
|
context <- newProbeContext
|
||||||
context <- newProbeContext directory
|
user <- requireEnv "NUTHATCH_OIDC_USER_A"
|
||||||
user <- requireEnv "NUTHATCH_OIDC_USER_A"
|
session <- login context False user "product"
|
||||||
session <- login context False user "product"
|
getArgs >>= \case
|
||||||
getArgs >>= \case
|
["verify"] -> verifyProduct context session
|
||||||
["verify"] -> verifyProduct context session
|
["timeout"] -> verifyTimeout context session
|
||||||
["timeout"] -> verifyTimeout context session
|
_ -> fail "expected verify or timeout mode"
|
||||||
_ -> fail "expected verify or timeout mode"
|
|
||||||
|
|
||||||
verifyTimeout :: ProbeContext -> Session -> IO ()
|
verifyTimeout :: ProbeContext -> Session -> IO ()
|
||||||
verifyTimeout context session = do
|
verifyTimeout context session = do
|
||||||
response <- curl
|
response <- authenticatedGetWith context session
|
||||||
[ "--get"
|
defaultRequestOptions
|
||||||
, "--cookie", sessionCookies session
|
{ optionQuery =
|
||||||
, "--data-urlencode", "root=personal"
|
[ ("root", Just "personal")
|
||||||
, "--data-urlencode", "q=timeout-probe"
|
, ("q", Just "timeout-probe")
|
||||||
, probeBase context <> "/api/browse"
|
]
|
||||||
]
|
}
|
||||||
assertEqual "timed search status" 200 (curlStatus response)
|
(probeBase context <> "/api/browse")
|
||||||
assertContains "timed search bound" "\"partial\":true" (curlBody response)
|
assertEqual "timed search status" 200 (probeStatus response)
|
||||||
|
assertContains "timed search bound" "\"partial\":true" (probeBody response)
|
||||||
putStrLn "Timed search bound passed"
|
putStrLn "Timed search bound passed"
|
||||||
|
|
||||||
verifyProduct :: ProbeContext -> Session -> IO ()
|
verifyProduct :: ProbeContext -> Session -> IO ()
|
||||||
|
|
@ -48,20 +47,15 @@ verifyProduct context session = do
|
||||||
|
|
||||||
verifyApplicationAssets :: ProbeContext -> Session -> IO ()
|
verifyApplicationAssets :: ProbeContext -> Session -> IO ()
|
||||||
verifyApplicationAssets context session = do
|
verifyApplicationAssets context session = do
|
||||||
sessionResponse <- curl
|
sessionResponse <- authenticatedGet context session
|
||||||
[ "--cookie", sessionCookies session
|
(probeBase context <> "/api/session")
|
||||||
, probeBase context <> "/api/session"
|
assertEqual "session endpoint status" 200 (probeStatus sessionResponse)
|
||||||
]
|
assertContains "session user" "\"user_id\":" (probeBody sessionResponse)
|
||||||
assertEqual "session endpoint status" 200 (curlStatus sessionResponse)
|
|
||||||
assertContains "session user" "\"user_id\":" (curlBody sessionResponse)
|
|
||||||
assertContains "session CSRF"
|
assertContains "session CSRF"
|
||||||
("\"csrf\":\"" <> sessionCsrf session <> "\"")
|
("\"csrf\":\"" <> sessionCsrf session <> "\"")
|
||||||
(curlBody sessionResponse)
|
(probeBody sessionResponse)
|
||||||
application <- curl
|
application <- authenticatedGet context session (probeBase context <> "/app")
|
||||||
[ "--cookie", sessionCookies session
|
assertEqual "application status" 200 (probeStatus application)
|
||||||
, probeBase context <> "/app"
|
|
||||||
]
|
|
||||||
assertEqual "application status" 200 (curlStatus application)
|
|
||||||
csp <- maybe (fail "application omitted CSP") pure $
|
csp <- maybe (fail "application omitted CSP") pure $
|
||||||
headerValue "content-security-policy" application
|
headerValue "content-security-policy" application
|
||||||
mapM_
|
mapM_
|
||||||
|
|
@ -70,18 +64,16 @@ verifyApplicationAssets context session = do
|
||||||
directive
|
directive
|
||||||
csp)
|
csp)
|
||||||
["default-src 'none'", "script-src 'self'", "frame-ancestors 'none'"]
|
["default-src 'none'", "script-src 'self'", "frame-ancestors 'none'"]
|
||||||
asset <- between "src=\"/assets/" "\"" (B8.unpack (curlBody application))
|
asset <- between "src=\"/assets/" "\"" (B8.unpack (probeBody application))
|
||||||
javascript <- curl
|
javascript <- authenticatedGet context session
|
||||||
[ "--cookie", sessionCookies session
|
(probeBase context <> "/assets/" <> asset)
|
||||||
, probeBase context <> "/assets/" <> asset
|
assertEqual "asset status" 200 (probeStatus javascript)
|
||||||
]
|
|
||||||
assertEqual "asset status" 200 (curlStatus javascript)
|
|
||||||
assertContains "asset content type" "javascript" $
|
assertContains "asset content type" "javascript" $
|
||||||
maybe BS.empty id (headerValue "content-type" javascript)
|
maybe BS.empty id (headerValue "content-type" javascript)
|
||||||
assertEqual "asset nosniff"
|
assertEqual "asset nosniff"
|
||||||
(Just "nosniff")
|
(Just "nosniff")
|
||||||
(headerValue "x-content-type-options" javascript)
|
(headerValue "x-content-type-options" javascript)
|
||||||
let visible = curlBody application <> curlBody javascript
|
let visible = probeBody application <> probeBody javascript
|
||||||
mapM_
|
mapM_
|
||||||
(\value ->
|
(\value ->
|
||||||
assert ("browser artifact exposed " <> value) $
|
assert ("browser artifact exposed " <> value) $
|
||||||
|
|
@ -95,7 +87,7 @@ verifyApplicationAssets context session = do
|
||||||
(\marker ->
|
(\marker ->
|
||||||
assertContains ("asset omitted " <> marker)
|
assertContains ("asset omitted " <> marker)
|
||||||
(B8.pack marker)
|
(B8.pack marker)
|
||||||
(curlBody javascript))
|
(probeBody javascript))
|
||||||
[ "Drop a file here"
|
[ "Drop a file here"
|
||||||
, "Shared root"
|
, "Shared root"
|
||||||
, "Retry same upload"
|
, "Retry same upload"
|
||||||
|
|
@ -103,10 +95,12 @@ verifyApplicationAssets context session = do
|
||||||
, "nuthatch-recursive-delete"
|
, "nuthatch-recursive-delete"
|
||||||
, "URLSearchParams"
|
, "URLSearchParams"
|
||||||
]
|
]
|
||||||
unauthenticatedApp <- curl [probeBase context <> "/app"]
|
unauthenticatedApp <- get (probeClient context) (probeBase context <> "/app")
|
||||||
assertEqual "unauthenticated application" 303 (curlStatus unauthenticatedApp)
|
assertEqual "unauthenticated application" 303 (probeStatus unauthenticatedApp)
|
||||||
unauthenticatedAsset <- curl [probeBase context <> "/assets/" <> asset]
|
unauthenticatedAsset <- get
|
||||||
assertEqual "unauthenticated asset" 303 (curlStatus unauthenticatedAsset)
|
(probeClient context)
|
||||||
|
(probeBase context <> "/assets/" <> asset)
|
||||||
|
assertEqual "unauthenticated asset" 303 (probeStatus unauthenticatedAsset)
|
||||||
|
|
||||||
verifyPaginationAndSearch :: ProbeContext -> Session -> IO ()
|
verifyPaginationAndSearch :: ProbeContext -> Session -> IO ()
|
||||||
verifyPaginationAndSearch context session = do
|
verifyPaginationAndSearch context session = do
|
||||||
|
|
@ -119,34 +113,36 @@ verifyPaginationAndSearch context session = do
|
||||||
"81000000-0000-4000-8000-000000000002" "beta"
|
"81000000-0000-4000-8000-000000000002" "beta"
|
||||||
uploadSucceeded context session "product-browse/gamma.txt"
|
uploadSucceeded context session "product-browse/gamma.txt"
|
||||||
"81000000-0000-4000-8000-000000000003" "gamma"
|
"81000000-0000-4000-8000-000000000003" "gamma"
|
||||||
firstPage <- curl
|
firstPage <- authenticatedGetWith context session
|
||||||
[ "--get"
|
defaultRequestOptions
|
||||||
, "--cookie", sessionCookies session
|
{ optionQuery =
|
||||||
, "--data-urlencode", "root=personal"
|
[ ("root", Just "personal")
|
||||||
, "--data-urlencode", "prefix=product-browse"
|
, ("prefix", Just "product-browse")
|
||||||
, probeBase context <> "/api/browse"
|
]
|
||||||
]
|
}
|
||||||
|
(probeBase context <> "/api/browse")
|
||||||
requireStatus "first browse page" 200 firstPage
|
requireStatus "first browse page" 200 firstPage
|
||||||
assertContains "first page Alpha"
|
assertContains "first page Alpha"
|
||||||
"\"path\":\"product-browse/Alpha.txt\""
|
"\"path\":\"product-browse/Alpha.txt\""
|
||||||
(curlBody firstPage)
|
(probeBody firstPage)
|
||||||
assertContains "first page beta"
|
assertContains "first page beta"
|
||||||
"\"path\":\"product-browse/beta.txt\""
|
"\"path\":\"product-browse/beta.txt\""
|
||||||
(curlBody firstPage)
|
(probeBody firstPage)
|
||||||
assert "first page exceeded configured size" $
|
assert "first page exceeded configured size" $
|
||||||
not ("product-browse/gamma.txt" `BS.isInfixOf` curlBody firstPage)
|
not ("product-browse/gamma.txt" `BS.isInfixOf` probeBody firstPage)
|
||||||
continuation <- stringField "continuation" (curlBody firstPage)
|
continuation <- stringField "continuation" (probeBody firstPage)
|
||||||
secondPage <- curl
|
secondPage <- authenticatedGetWith context session
|
||||||
[ "--get"
|
defaultRequestOptions
|
||||||
, "--cookie", sessionCookies session
|
{ optionQuery =
|
||||||
, "--data-urlencode", "root=personal"
|
[ ("root", Just "personal")
|
||||||
, "--data-urlencode", "prefix=product-browse"
|
, ("prefix", Just "product-browse")
|
||||||
, "--data-urlencode", "continuation=" <> continuation
|
, ("continuation", Just (B8.pack continuation))
|
||||||
, probeBase context <> "/api/browse"
|
]
|
||||||
]
|
}
|
||||||
|
(probeBase context <> "/api/browse")
|
||||||
assertContains "second browse page"
|
assertContains "second browse page"
|
||||||
"\"path\":\"product-browse/gamma.txt\""
|
"\"path\":\"product-browse/gamma.txt\""
|
||||||
(curlBody secondPage)
|
(probeBody secondPage)
|
||||||
mapM_
|
mapM_
|
||||||
(\(path, operationId, body) ->
|
(\(path, operationId, body) ->
|
||||||
uploadSucceeded context session path operationId body)
|
uploadSucceeded context session path operationId body)
|
||||||
|
|
@ -155,49 +151,48 @@ verifyPaginationAndSearch context session = do
|
||||||
, ("002-filler.txt", "81000000-0000-4000-8000-000000000006", "three")
|
, ("002-filler.txt", "81000000-0000-4000-8000-000000000006", "three")
|
||||||
, ("003-needle-after-bound.txt", "81000000-0000-4000-8000-000000000007", "four")
|
, ("003-needle-after-bound.txt", "81000000-0000-4000-8000-000000000007", "four")
|
||||||
]
|
]
|
||||||
searched <- curl
|
searched <- authenticatedGetWith context session
|
||||||
[ "--get"
|
defaultRequestOptions
|
||||||
, "--cookie", sessionCookies session
|
{ optionQuery =
|
||||||
, "--data-urlencode", "root=personal"
|
[ ("root", Just "personal")
|
||||||
, "--data-urlencode", "q=NEEDLE"
|
, ("q", Just "NEEDLE")
|
||||||
, probeBase context <> "/api/browse"
|
]
|
||||||
]
|
}
|
||||||
|
(probeBase context <> "/api/browse")
|
||||||
mapM_
|
mapM_
|
||||||
(\value -> assertContains "bounded case-insensitive search" value (curlBody searched))
|
(\value -> assertContains "bounded case-insensitive search" value (probeBody searched))
|
||||||
[ "\"path\":\"000-Needle-A.txt\""
|
[ "\"path\":\"000-Needle-A.txt\""
|
||||||
, "\"path\":\"001-needle-b.txt\""
|
, "\"path\":\"001-needle-b.txt\""
|
||||||
, "\"partial\":true"
|
, "\"partial\":true"
|
||||||
]
|
]
|
||||||
assert "search crossed the configured scan bound" $
|
assert "search crossed the configured scan bound" $
|
||||||
not ("003-needle-after-bound.txt" `BS.isInfixOf` curlBody searched)
|
not ("003-needle-after-bound.txt" `BS.isInfixOf` probeBody searched)
|
||||||
|
|
||||||
verifyCanonicalPaths :: ProbeContext -> Session -> IO ()
|
verifyCanonicalPaths :: ProbeContext -> Session -> IO ()
|
||||||
verifyCanonicalPaths context session = do
|
verifyCanonicalPaths context session = do
|
||||||
mapM_
|
mapM_
|
||||||
(\url -> curl ["--cookie", sessionCookies session, url] >>=
|
(\url -> authenticatedGet context session url >>=
|
||||||
requireStatus "non-canonical path" 400)
|
requireStatus "non-canonical path" 400)
|
||||||
[ probeBase context <> "/api/browse?root=Personal"
|
[ probeBase context <> "/api/browse?root=Personal"
|
||||||
, probeBase context <> "/api/browse?root=personal&prefix=a//b"
|
, probeBase context <> "/api/browse?root=personal&prefix=a//b"
|
||||||
, probeBase context <> "/api/download?root=personal&path=./file"
|
, probeBase context <> "/api/download?root=personal&path=./file"
|
||||||
]
|
]
|
||||||
decomposed <- curl
|
decomposed <- authenticatedGet context session
|
||||||
[ "--cookie", sessionCookies session
|
(probeBase context
|
||||||
, probeBase context
|
|
||||||
<> "/api/download?root=personal&path=cafe%CC%81.txt"
|
<> "/api/download?root=personal&path=cafe%CC%81.txt"
|
||||||
]
|
)
|
||||||
requireStatus "non-NFC path" 400 decomposed
|
requireStatus "non-NFC path" 400 decomposed
|
||||||
uploadSucceeded context session "encoded%2Fname.txt"
|
uploadSucceeded context session "encoded%2Fname.txt"
|
||||||
"81000000-0000-4000-8000-000000000008" "encoded"
|
"81000000-0000-4000-8000-000000000008" "encoded"
|
||||||
encoded <- curl
|
encoded <- authenticatedGet context session
|
||||||
[ "--cookie", sessionCookies session
|
(probeBase context
|
||||||
, probeBase context
|
|
||||||
<> "/api/download?root=personal&path=encoded%252Fname.txt"
|
<> "/api/download?root=personal&path=encoded%252Fname.txt"
|
||||||
]
|
)
|
||||||
assertEqual "encoded path body" "encoded" (curlBody encoded)
|
assertEqual "encoded path body" "encoded" (probeBody encoded)
|
||||||
uploadSucceeded context session "café.txt"
|
uploadSucceeded context session "café.txt"
|
||||||
"81000000-0000-4000-8000-000000000014" "unicode"
|
"81000000-0000-4000-8000-000000000014" "unicode"
|
||||||
unicode <- download context session "personal" "café.txt"
|
unicode <- download context session "personal" "café.txt"
|
||||||
assertEqual "Unicode path body" "unicode" (curlBody unicode)
|
assertEqual "Unicode path body" "unicode" (probeBody unicode)
|
||||||
assertContains "Unicode filename"
|
assertContains "Unicode filename"
|
||||||
"filename*=UTF-8''caf%C3%A9.txt"
|
"filename*=UTF-8''caf%C3%A9.txt"
|
||||||
(maybe BS.empty id (headerValue "content-disposition" unicode))
|
(maybe BS.empty id (headerValue "content-disposition" unicode))
|
||||||
|
|
@ -207,15 +202,13 @@ verifySafeDownload context session = do
|
||||||
let svg = "<svg onload=\"alert(1)\"></svg>"
|
let svg = "<svg onload=\"alert(1)\"></svg>"
|
||||||
uploadSucceeded context session "active.svg"
|
uploadSucceeded context session "active.svg"
|
||||||
"81000000-0000-4000-8000-000000000009" svg
|
"81000000-0000-4000-8000-000000000009" svg
|
||||||
ranged <- curl
|
ranged <- authenticatedGetWith context session
|
||||||
[ "--cookie", sessionCookies session
|
defaultRequestOptions { optionHeaders = [("Range", "bytes=0-1")] }
|
||||||
, "--header", "Range: bytes=0-1"
|
(probeBase context <> "/api/download?root=personal&path=active.svg")
|
||||||
, probeBase context <> "/api/download?root=personal&path=active.svg"
|
|
||||||
]
|
|
||||||
requireStatus "unsupported range" 416 ranged
|
requireStatus "unsupported range" 416 ranged
|
||||||
assertContains "range error"
|
assertContains "range error"
|
||||||
"\"error\":\"range_not_supported\""
|
"\"error\":\"range_not_supported\""
|
||||||
(curlBody ranged)
|
(probeBody ranged)
|
||||||
downloaded <- download context session "personal" "active.svg"
|
downloaded <- download context session "personal" "active.svg"
|
||||||
requireStatus "safe download" 200 downloaded
|
requireStatus "safe download" 200 downloaded
|
||||||
let disposition =
|
let disposition =
|
||||||
|
|
@ -228,7 +221,7 @@ verifySafeDownload context session = do
|
||||||
assertContains "download sandbox"
|
assertContains "download sandbox"
|
||||||
"sandbox"
|
"sandbox"
|
||||||
(maybe BS.empty id (headerValue "content-security-policy" downloaded))
|
(maybe BS.empty id (headerValue "content-security-policy" downloaded))
|
||||||
assertEqual "download body" svg (curlBody downloaded)
|
assertEqual "download body" svg (probeBody downloaded)
|
||||||
|
|
||||||
verifyRecursiveDelete :: ProbeContext -> Session -> IO ()
|
verifyRecursiveDelete :: ProbeContext -> Session -> IO ()
|
||||||
verifyRecursiveDelete context session = do
|
verifyRecursiveDelete context session = do
|
||||||
|
|
@ -242,9 +235,9 @@ verifyRecursiveDelete context session = do
|
||||||
planned <- deletePlan context session
|
planned <- deletePlan context session
|
||||||
assertContains "first delete plan"
|
assertContains "first delete plan"
|
||||||
"\"paths\":[\"delete-tree/a.txt\",\"delete-tree/b.txt\"]"
|
"\"paths\":[\"delete-tree/a.txt\",\"delete-tree/b.txt\"]"
|
||||||
(curlBody planned)
|
(probeBody planned)
|
||||||
assert "first delete plan omitted continuation" $
|
assert "first delete plan omitted continuation" $
|
||||||
not ("\"continuation\":null" `BS.isInfixOf` curlBody planned)
|
not ("\"continuation\":null" `BS.isInfixOf` probeBody planned)
|
||||||
let firstBatch =
|
let firstBatch =
|
||||||
"{\"root\":\"personal\",\"csrf\":\""
|
"{\"root\":\"personal\",\"csrf\":\""
|
||||||
<> B8.unpack (sessionCsrf session)
|
<> B8.unpack (sessionCsrf session)
|
||||||
|
|
@ -262,7 +255,7 @@ verifyRecursiveDelete context session = do
|
||||||
restarted <- deletePlan context session
|
restarted <- deletePlan context session
|
||||||
assertContains "restarted delete plan"
|
assertContains "restarted delete plan"
|
||||||
"\"paths\":[\"delete-tree/c.txt\",\"delete-tree/d-concurrent.txt\"]"
|
"\"paths\":[\"delete-tree/c.txt\",\"delete-tree/d-concurrent.txt\"]"
|
||||||
(curlBody restarted)
|
(probeBody restarted)
|
||||||
let secondBatch =
|
let secondBatch =
|
||||||
"{\"root\":\"personal\",\"csrf\":\""
|
"{\"root\":\"personal\",\"csrf\":\""
|
||||||
<> B8.unpack (sessionCsrf session)
|
<> B8.unpack (sessionCsrf session)
|
||||||
|
|
@ -274,39 +267,42 @@ verifyRecursiveDelete context session = do
|
||||||
second <- deleteBatch context session secondBatch
|
second <- deleteBatch context session secondBatch
|
||||||
requireBatchSucceeded "second delete batch" second
|
requireBatchSucceeded "second delete batch" second
|
||||||
empty <- deletePlan context session
|
empty <- deletePlan context session
|
||||||
assertContains "empty delete plan" "\"paths\":[]" (curlBody empty)
|
assertContains "empty delete plan" "\"paths\":[]" (probeBody empty)
|
||||||
assertContains "empty delete continuation"
|
assertContains "empty delete continuation"
|
||||||
"\"continuation\":null"
|
"\"continuation\":null"
|
||||||
(curlBody empty)
|
(probeBody empty)
|
||||||
|
|
||||||
deletePlan :: ProbeContext -> Session -> IO CurlResponse
|
deletePlan :: ProbeContext -> Session -> IO ProbeResponse
|
||||||
deletePlan context session =
|
deletePlan context session =
|
||||||
curl
|
authenticatedGetWith context session
|
||||||
[ "--get"
|
defaultRequestOptions
|
||||||
, "--cookie", sessionCookies session
|
{ optionQuery =
|
||||||
, "--data-urlencode", "root=personal"
|
[ ("root", Just "personal")
|
||||||
, "--data-urlencode", "path=delete-tree"
|
, ("path", Just "delete-tree")
|
||||||
, probeBase context <> "/api/delete-plan"
|
]
|
||||||
]
|
}
|
||||||
|
(probeBase context <> "/api/delete-plan")
|
||||||
|
|
||||||
deleteBatch
|
deleteBatch
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
-> Session
|
-> Session
|
||||||
-> String
|
-> String
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
deleteBatch context session body =
|
deleteBatch context session body =
|
||||||
curl
|
postWith
|
||||||
[ "--cookie", sessionCookies session
|
(probeClient context)
|
||||||
, "--header", "Content-Type: application/json"
|
defaultRequestOptions
|
||||||
, "--data", body
|
{ optionCookies = Just (sessionCookies session)
|
||||||
, probeBase context <> "/api/delete-batch"
|
, optionHeaders = [("Content-Type", "application/json")]
|
||||||
]
|
, optionBody = ByteBody (B8.pack body)
|
||||||
|
}
|
||||||
|
(probeBase context <> "/api/delete-batch")
|
||||||
|
|
||||||
requireBatchSucceeded :: String -> CurlResponse -> IO ()
|
requireBatchSucceeded :: String -> ProbeResponse -> IO ()
|
||||||
requireBatchSucceeded label response = do
|
requireBatchSucceeded label response = do
|
||||||
requireStatus label 200 response
|
requireStatus label 200 response
|
||||||
assertEqual label 2 $
|
assertEqual label 2 $
|
||||||
countOccurrences "\"result\":\"succeeded\"" (curlBody response)
|
countOccurrences "\"result\":\"succeeded\"" (probeBody response)
|
||||||
|
|
||||||
uploadSucceeded
|
uploadSucceeded
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
|
|
@ -319,14 +315,33 @@ uploadSucceeded context session path operationId body =
|
||||||
upload context session "personal" path operationId body >>=
|
upload context session "personal" path operationId body >>=
|
||||||
requireSucceeded ("upload " <> path)
|
requireSucceeded ("upload " <> path)
|
||||||
|
|
||||||
requireSucceeded :: String -> CurlResponse -> IO ()
|
requireSucceeded :: String -> ProbeResponse -> IO ()
|
||||||
requireSucceeded label response = do
|
requireSucceeded label response = do
|
||||||
requireStatus label 200 response
|
requireStatus label 200 response
|
||||||
assertContains label "\"result\":\"succeeded\"" (curlBody response)
|
assertContains label "\"result\":\"succeeded\"" (probeBody response)
|
||||||
|
|
||||||
requireStatus :: String -> Int -> CurlResponse -> IO ()
|
requireStatus :: String -> Int -> ProbeResponse -> IO ()
|
||||||
requireStatus label expected response =
|
requireStatus label expected response =
|
||||||
assertEqual (label <> " status") expected (curlStatus response)
|
assertEqual (label <> " status") expected (probeStatus response)
|
||||||
|
|
||||||
|
authenticatedGet
|
||||||
|
:: ProbeContext
|
||||||
|
-> Session
|
||||||
|
-> String
|
||||||
|
-> IO ProbeResponse
|
||||||
|
authenticatedGet context session =
|
||||||
|
authenticatedGetWith context session defaultRequestOptions
|
||||||
|
|
||||||
|
authenticatedGetWith
|
||||||
|
:: ProbeContext
|
||||||
|
-> Session
|
||||||
|
-> RequestOptions
|
||||||
|
-> String
|
||||||
|
-> IO ProbeResponse
|
||||||
|
authenticatedGetWith context session options =
|
||||||
|
getWith
|
||||||
|
(probeClient context)
|
||||||
|
options { optionCookies = Just (sessionCookies session) }
|
||||||
|
|
||||||
stringField :: String -> BS.ByteString -> IO String
|
stringField :: String -> BS.ByteString -> IO String
|
||||||
stringField name bytes =
|
stringField name bytes =
|
||||||
|
|
|
||||||
|
|
@ -4,37 +4,35 @@ import Control.Concurrent (threadDelay)
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Char8 as B8
|
import qualified Data.ByteString.Char8 as B8
|
||||||
import Nuthatch.Probe.Assert
|
import Nuthatch.Probe.Assert
|
||||||
import Nuthatch.Probe.Curl
|
import Nuthatch.Probe.Http
|
||||||
import Nuthatch.Probe.Oidc
|
import Nuthatch.Probe.Oidc
|
||||||
import Nuthatch.Probe.ProductClient
|
import Nuthatch.Probe.ProductClient
|
||||||
import System.Environment (getArgs)
|
import System.Environment (getArgs)
|
||||||
import System.IO (hFlush, stdout)
|
import System.IO (hFlush, stdout)
|
||||||
import System.IO.Temp (withSystemTempDirectory)
|
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main =
|
main = do
|
||||||
withSystemTempDirectory "nuthatch-runtime-http-probe" $ \directory -> do
|
context <- newProbeContext
|
||||||
context <- newProbeContext directory
|
getArgs >>= \case
|
||||||
getArgs >>= \case
|
["health", expectedText] ->
|
||||||
["health", expectedText] ->
|
maybe
|
||||||
maybe
|
(fail "invalid expected readiness status")
|
||||||
(fail "invalid expected readiness status")
|
(verifyHealth context)
|
||||||
(verifyHealth context)
|
(readMaybe expectedText)
|
||||||
(readMaybe expectedText)
|
["baseline"] -> withSession context True "runtime-baseline" $
|
||||||
["baseline"] -> withSession context True "runtime-baseline" $
|
verifyBaseline context
|
||||||
verifyBaseline context
|
["coherence"] -> verifyCoherence context
|
||||||
["coherence"] -> verifyCoherence context
|
["redis-unavailable"] ->
|
||||||
["redis-unavailable"] ->
|
withSession context False "runtime-redis" $
|
||||||
withSession context False "runtime-redis" $
|
verifyRedisUnavailable context
|
||||||
verifyRedisUnavailable context
|
["database-unavailable"] ->
|
||||||
["database-unavailable"] ->
|
withSession context False "runtime-database" $
|
||||||
withSession context False "runtime-database" $
|
verifyDatabaseUnavailable context
|
||||||
verifyDatabaseUnavailable context
|
_ ->
|
||||||
_ ->
|
fail
|
||||||
fail
|
"expected health STATUS, baseline, coherence, redis-unavailable, \
|
||||||
"expected health STATUS, baseline, coherence, redis-unavailable, \
|
\or database-unavailable"
|
||||||
\or database-unavailable"
|
|
||||||
|
|
||||||
withSession
|
withSession
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
|
|
@ -48,20 +46,20 @@ withSession context verifyBoundary suffix action = do
|
||||||
|
|
||||||
verifyHealth :: ProbeContext -> Int -> IO ()
|
verifyHealth :: ProbeContext -> Int -> IO ()
|
||||||
verifyHealth context expectedReady = do
|
verifyHealth context expectedReady = do
|
||||||
health <- curl [probeBase context <> "/healthz"]
|
health <- get (probeClient context) (probeBase context <> "/healthz")
|
||||||
assertEqual "runtime health status" 200 (curlStatus health)
|
assertEqual "runtime health status" 200 (probeStatus health)
|
||||||
assertContains "runtime health body" "\"ok\":true" (curlBody health)
|
assertContains "runtime health body" "\"ok\":true" (probeBody health)
|
||||||
ready <- curl [probeBase context <> "/readyz"]
|
ready <- get (probeClient context) (probeBase context <> "/readyz")
|
||||||
assertEqual "runtime readiness status" expectedReady (curlStatus ready)
|
assertEqual "runtime readiness status" expectedReady (probeStatus ready)
|
||||||
_ <- curl [probeBase context <> "/app"]
|
_ <- get (probeClient context) (probeBase context <> "/app")
|
||||||
session <- curl [probeBase context <> "/api/session"]
|
session <- get (probeClient context) (probeBase context <> "/api/session")
|
||||||
assertEqual "runtime unauthenticated session" 303 (curlStatus session)
|
assertEqual "runtime unauthenticated session" 303 (probeStatus session)
|
||||||
putStrLn "Runtime health semantics passed"
|
putStrLn "Runtime health semantics passed"
|
||||||
|
|
||||||
verifyBaseline :: ProbeContext -> Session -> IO ()
|
verifyBaseline :: ProbeContext -> Session -> IO ()
|
||||||
verifyBaseline context sessionA = do
|
verifyBaseline context sessionA = do
|
||||||
browsed <- browse context sessionA ""
|
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"
|
uploaded <- upload context sessionA "personal" "cross-user.txt"
|
||||||
"76000000-0000-4000-8000-000000000001" "private-a"
|
"76000000-0000-4000-8000-000000000001" "private-a"
|
||||||
requireSucceeded "runtime baseline put" uploaded
|
requireSucceeded "runtime baseline put" uploaded
|
||||||
|
|
@ -69,30 +67,34 @@ verifyBaseline context sessionA = do
|
||||||
sessionB <- login context False userB "runtime-baseline-b"
|
sessionB <- login context False userB "runtime-baseline-b"
|
||||||
personalB <- browse context sessionB "?root=personal"
|
personalB <- browse context sessionB "?root=personal"
|
||||||
assert "runtime personal cache crossed users" $
|
assert "runtime personal cache crossed users" $
|
||||||
not ("cross-user.txt" `BS.isInfixOf` curlBody personalB)
|
not ("cross-user.txt" `BS.isInfixOf` probeBody personalB)
|
||||||
wrongLogout <- curl
|
wrongLogout <- postWith
|
||||||
[ "--cookie", sessionCookies sessionA
|
(probeClient context)
|
||||||
, "--header"
|
defaultRequestOptions
|
||||||
, "X-CSRF-Token: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
{ optionCookies = Just (sessionCookies sessionA)
|
||||||
, "--request", "POST"
|
, optionHeaders =
|
||||||
, probeBase context <> "/logout"
|
[ ( "X-CSRF-Token"
|
||||||
]
|
, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
(probeBase context <> "/logout")
|
||||||
assert "runtime baseline accepted wrong logout CSRF" $
|
assert "runtime baseline accepted wrong logout CSRF" $
|
||||||
curlStatus wrongLogout >= 400
|
probeStatus wrongLogout >= 400
|
||||||
deleted <- deleteObject context sessionA "personal" "cross-user.txt"
|
deleted <- deleteObject context sessionA "personal" "cross-user.txt"
|
||||||
"76000000-0000-4000-8000-000000000002"
|
"76000000-0000-4000-8000-000000000002"
|
||||||
requireSucceeded "runtime baseline delete" deleted
|
requireSucceeded "runtime baseline delete" deleted
|
||||||
restored <- upload context sessionA "personal" "cross-user.txt"
|
restored <- upload context sessionA "personal" "cross-user.txt"
|
||||||
"76000000-0000-4000-8000-000000000003" "private-a"
|
"76000000-0000-4000-8000-000000000003" "private-a"
|
||||||
requireSucceeded "runtime unavailable fixture" restored
|
requireSucceeded "runtime unavailable fixture" restored
|
||||||
loggedOut <- curl
|
loggedOut <- postWith
|
||||||
[ "--cookie", sessionCookies sessionA
|
(probeClient context)
|
||||||
, "--cookie-jar", sessionCookies sessionA
|
defaultRequestOptions
|
||||||
, "--header", "X-CSRF-Token: " <> B8.unpack (sessionCsrf sessionA)
|
{ optionCookies = Just (sessionCookies sessionA)
|
||||||
, "--request", "POST"
|
, optionHeaders = [("X-CSRF-Token", sessionCsrf sessionA)]
|
||||||
, probeBase context <> "/logout"
|
}
|
||||||
]
|
(probeBase context <> "/logout")
|
||||||
assertEqual "runtime baseline logout" 200 (curlStatus loggedOut)
|
assertEqual "runtime baseline logout" 200 (probeStatus loggedOut)
|
||||||
putStrLn "Runtime audit baseline passed"
|
putStrLn "Runtime audit baseline passed"
|
||||||
|
|
||||||
verifyCoherence :: ProbeContext -> IO ()
|
verifyCoherence :: ProbeContext -> IO ()
|
||||||
|
|
@ -113,33 +115,33 @@ verifyCoherence context = do
|
||||||
rootListing <- browse context sessionB "?root=shared"
|
rootListing <- browse context sessionB "?root=shared"
|
||||||
assertContains "shared root cache invalidation"
|
assertContains "shared root cache invalidation"
|
||||||
"runtime"
|
"runtime"
|
||||||
(curlBody rootListing)
|
(probeBody rootListing)
|
||||||
prefixListing <- browse context sessionB "?root=shared&prefix=runtime"
|
prefixListing <- browse context sessionB "?root=shared&prefix=runtime"
|
||||||
assertContains "shared prefix cache invalidation"
|
assertContains "shared prefix cache invalidation"
|
||||||
"coherence.txt"
|
"coherence.txt"
|
||||||
(curlBody prefixListing)
|
(probeBody prefixListing)
|
||||||
deleted <- deleteObject context sessionA "shared" "runtime/coherence.txt"
|
deleted <- deleteObject context sessionA "shared" "runtime/coherence.txt"
|
||||||
"74000000-0000-4000-8000-000000000002"
|
"74000000-0000-4000-8000-000000000002"
|
||||||
requireSucceeded "shared coherence delete" deleted
|
requireSucceeded "shared coherence delete" deleted
|
||||||
afterDelete <- browse context sessionB "?root=shared&prefix=runtime"
|
afterDelete <- browse context sessionB "?root=shared&prefix=runtime"
|
||||||
assert "shared deletion remained in another replica cache" $
|
assert "shared deletion remained in another replica cache" $
|
||||||
not ("coherence.txt" `BS.isInfixOf` curlBody afterDelete)
|
not ("coherence.txt" `BS.isInfixOf` probeBody afterDelete)
|
||||||
threadDelay 2000000
|
threadDelay 2000000
|
||||||
browse context sessionB "?root=shared" >>= \response ->
|
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"
|
putStrLn "Cross-user cache coherence and TTL passed"
|
||||||
|
|
||||||
verifyRedisUnavailable :: ProbeContext -> Session -> IO ()
|
verifyRedisUnavailable :: ProbeContext -> Session -> IO ()
|
||||||
verifyRedisUnavailable context session = do
|
verifyRedisUnavailable context session = do
|
||||||
listing <- browse context session "?root=personal"
|
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"
|
assertContains "Redis-unavailable browse fixture"
|
||||||
"cross-user.txt"
|
"cross-user.txt"
|
||||||
(curlBody listing)
|
(probeBody listing)
|
||||||
searched <- browse context session "?root=personal&q=CROSS-USER"
|
searched <- browse context session "?root=personal&q=CROSS-USER"
|
||||||
assertContains "Redis-unavailable search"
|
assertContains "Redis-unavailable search"
|
||||||
"cross-user.txt"
|
"cross-user.txt"
|
||||||
(curlBody searched)
|
(probeBody searched)
|
||||||
unavailableUpload <- upload context session "personal" "unavailable.bin"
|
unavailableUpload <- upload context session "personal" "unavailable.bin"
|
||||||
"72000000-0000-4000-8000-000000000001" "x"
|
"72000000-0000-4000-8000-000000000001" "x"
|
||||||
requireError "Redis-unavailable upload"
|
requireError "Redis-unavailable upload"
|
||||||
|
|
@ -168,22 +170,22 @@ verifyDatabaseUnavailable context session = do
|
||||||
503 "mutation_rejected" unavailableDelete
|
503 "mutation_rejected" unavailableDelete
|
||||||
putStrLn "Database-unavailable mutation behavior passed"
|
putStrLn "Database-unavailable mutation behavior passed"
|
||||||
|
|
||||||
retryPastThrottle :: Int -> IO CurlResponse -> IO CurlResponse
|
retryPastThrottle :: Int -> IO ProbeResponse -> IO ProbeResponse
|
||||||
retryPastThrottle attempts action = do
|
retryPastThrottle attempts action = do
|
||||||
response <- action
|
response <- action
|
||||||
if attempts > 1
|
if attempts > 1
|
||||||
&& "\"error\":\"throttle_unavailable\"" `BS.isInfixOf` curlBody response
|
&& "\"error\":\"throttle_unavailable\"" `BS.isInfixOf` probeBody response
|
||||||
then threadDelay 250000 >> retryPastThrottle (attempts - 1) action
|
then threadDelay 250000 >> retryPastThrottle (attempts - 1) action
|
||||||
else pure response
|
else pure response
|
||||||
|
|
||||||
requireSucceeded :: String -> CurlResponse -> IO ()
|
requireSucceeded :: String -> ProbeResponse -> IO ()
|
||||||
requireSucceeded label response = do
|
requireSucceeded label response = do
|
||||||
assertEqual (label <> " status") 200 (curlStatus response)
|
assertEqual (label <> " status") 200 (probeStatus response)
|
||||||
assertContains label "\"result\":\"succeeded\"" (curlBody 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
|
requireError label status code response = do
|
||||||
assertEqual (label <> " status") status (curlStatus response)
|
assertEqual (label <> " status") status (probeStatus response)
|
||||||
assert
|
assert
|
||||||
(label <> " body: " <> B8.unpack (curlBody response))
|
(label <> " body: " <> B8.unpack (probeBody response))
|
||||||
(("\"error\":\"" <> code <> "\"") `BS.isInfixOf` curlBody response)
|
(("\"error\":\"" <> code <> "\"") `BS.isInfixOf` probeBody response)
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,14 @@ import System.Process (readProcessWithExitCode)
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
import Nuthatch.Probe.Assert
|
import Nuthatch.Probe.Assert
|
||||||
import Nuthatch.Probe.Curl
|
import Nuthatch.Probe.Curl
|
||||||
|
import Nuthatch.Probe.Http
|
||||||
import Nuthatch.Probe.Oidc
|
import Nuthatch.Probe.Oidc
|
||||||
import Nuthatch.Probe.ProductClient
|
import Nuthatch.Probe.ProductClient
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main =
|
main =
|
||||||
withSystemTempDirectory "nuthatch-streaming-probe" $ \directory -> do
|
withSystemTempDirectory "nuthatch-streaming-probe" $ \directory -> do
|
||||||
context <- newProbeContext directory
|
context <- newProbeContext
|
||||||
user <- requireEnv "NUTHATCH_OIDC_USER_A"
|
user <- requireEnv "NUTHATCH_OIDC_USER_A"
|
||||||
session <- login context False user "streaming-a"
|
session <- login context False user "streaming-a"
|
||||||
getArgs >>= \case
|
getArgs >>= \case
|
||||||
|
|
@ -58,14 +59,16 @@ verifyLarge context session directory = do
|
||||||
uploaded <- uploadFileDeclared
|
uploaded <- uploadFileDeclared
|
||||||
context session "streaming-large.bin"
|
context session "streaming-large.bin"
|
||||||
"70000000-0000-4000-8000-000000000001"
|
"70000000-0000-4000-8000-000000000001"
|
||||||
largeBytes largeDigest largePath []
|
largeBytes largeDigest largePath defaultRequestOptions
|
||||||
requireSucceeded "large streaming upload" uploaded
|
requireSucceeded "large streaming upload" uploaded
|
||||||
downloaded <- curlToFile downloadPath
|
downloaded <- downloadTo
|
||||||
[ "--cookie", sessionCookies session
|
(probeClient context)
|
||||||
, probeBase context
|
defaultRequestOptions
|
||||||
<> "/api/download?root=personal&path=streaming-large.bin"
|
{ optionCookies = Just (sessionCookies session) }
|
||||||
]
|
downloadPath
|
||||||
assertEqual "large streaming download status" 200 (curlStatus downloaded)
|
(probeBase context
|
||||||
|
<> "/api/download?root=personal&path=streaming-large.bin")
|
||||||
|
assertEqual "large streaming download status" 200 (probeStatus downloaded)
|
||||||
hashFile downloadPath >>= assertEqual "large download digest" largeDigest
|
hashFile downloadPath >>= assertEqual "large download digest" largeDigest
|
||||||
let tiny = "x"
|
let tiny = "x"
|
||||||
mapM_
|
mapM_
|
||||||
|
|
@ -73,25 +76,25 @@ verifyLarge context session directory = do
|
||||||
replay <- uploadBytesDeclared
|
replay <- uploadBytesDeclared
|
||||||
context session "streaming-large.bin"
|
context session "streaming-large.bin"
|
||||||
"70000000-0000-4000-8000-000000000007"
|
"70000000-0000-4000-8000-000000000007"
|
||||||
largeBytes largeDigest tiny []
|
largeBytes largeDigest tiny defaultRequestOptions
|
||||||
requireSucceeded ("prior/desired observation " <> label) replay)
|
requireSucceeded ("prior/desired observation " <> label) replay)
|
||||||
["first", "replay"]
|
["first", "replay"]
|
||||||
sizeMismatch <- uploadBytesDeclared
|
sizeMismatch <- uploadBytesDeclared
|
||||||
context session "size-mismatch.bin"
|
context session "size-mismatch.bin"
|
||||||
"70000000-0000-4000-8000-000000000002"
|
"70000000-0000-4000-8000-000000000002"
|
||||||
2 (sha256Hex tiny) tiny []
|
2 (sha256Hex tiny) tiny defaultRequestOptions
|
||||||
requireError "size mismatch" 400 "size_mismatch" sizeMismatch
|
requireError "size mismatch" 400 "size_mismatch" sizeMismatch
|
||||||
identityMismatch <- uploadBytesDeclared
|
identityMismatch <- uploadBytesDeclared
|
||||||
context session "identity-mismatch.bin"
|
context session "identity-mismatch.bin"
|
||||||
"70000000-0000-4000-8000-000000000003"
|
"70000000-0000-4000-8000-000000000003"
|
||||||
1 (BS.replicate 64 48) tiny []
|
1 (BS.replicate 64 48) tiny defaultRequestOptions
|
||||||
requireError "content identity mismatch"
|
requireError "content identity mismatch"
|
||||||
400 "content_identity_mismatch" identityMismatch
|
400 "content_identity_mismatch" identityMismatch
|
||||||
tooLarge <- uploadFileDeclared
|
tooLarge <- uploadFileDeclared
|
||||||
context session "too-large.bin"
|
context session "too-large.bin"
|
||||||
"70000000-0000-4000-8000-000000000004"
|
"70000000-0000-4000-8000-000000000004"
|
||||||
overLimit (sha256Hex tiny) largePath
|
overLimit (sha256Hex tiny) largePath
|
||||||
["--max-time", "5", "--limit-rate", "64k"]
|
defaultRequestOptions { optionTimeoutMicros = Just 5000000 }
|
||||||
requireError "object size limit" 413 "object_too_large" tooLarge
|
requireError "object size limit" 413 "object_too_large" tooLarge
|
||||||
aborted <- interruptedUpload
|
aborted <- interruptedUpload
|
||||||
context session "aborted-upload.bin"
|
context session "aborted-upload.bin"
|
||||||
|
|
@ -99,14 +102,14 @@ verifyLarge context session directory = do
|
||||||
largeBytes largeDigest largePath
|
largeBytes largeDigest largePath
|
||||||
assert "aborted upload unexpectedly completed" (not aborted)
|
assert "aborted upload unexpectedly completed" (not aborted)
|
||||||
missing <- download context session "personal" "aborted-upload.bin"
|
missing <- download context session "personal" "aborted-upload.bin"
|
||||||
assertEqual "aborted upload visibility" 404 (curlStatus missing)
|
assertEqual "aborted upload visibility" 404 (probeStatus missing)
|
||||||
replayAfterAbort <- uploadBytesDeclared
|
replayAfterAbort <- uploadBytesDeclared
|
||||||
context session "aborted-upload.bin"
|
context session "aborted-upload.bin"
|
||||||
"70000000-0000-4000-8000-000000000005"
|
"70000000-0000-4000-8000-000000000005"
|
||||||
largeBytes largeDigest tiny []
|
largeBytes largeDigest tiny defaultRequestOptions
|
||||||
assertEqual "aborted upload replay status" 200 (curlStatus replayAfterAbort)
|
assertEqual "aborted upload replay status" 200 (probeStatus replayAfterAbort)
|
||||||
assert "aborted upload replay lost active or failed identity" $
|
assert "aborted upload replay lost active or failed identity" $
|
||||||
any (`BS.isInfixOf` curlBody replayAfterAbort)
|
any (`BS.isInfixOf` probeBody replayAfterAbort)
|
||||||
["\"result\":\"pending\"", "\"result\":\"failed\""]
|
["\"result\":\"pending\"", "\"result\":\"failed\""]
|
||||||
abortedOverwrite <- interruptedUpload
|
abortedOverwrite <- interruptedUpload
|
||||||
context session "streaming-large.bin"
|
context session "streaming-large.bin"
|
||||||
|
|
@ -114,12 +117,14 @@ verifyLarge context session directory = do
|
||||||
largeBytes (BS.replicate 64 48) largePath
|
largeBytes (BS.replicate 64 48) largePath
|
||||||
assert "aborted overwrite unexpectedly completed" (not abortedOverwrite)
|
assert "aborted overwrite unexpectedly completed" (not abortedOverwrite)
|
||||||
preservedPath <- pure (directory <> "/preserved.bin")
|
preservedPath <- pure (directory <> "/preserved.bin")
|
||||||
preserved <- curlToFile preservedPath
|
preserved <- downloadTo
|
||||||
[ "--cookie", sessionCookies session
|
(probeClient context)
|
||||||
, probeBase context
|
defaultRequestOptions
|
||||||
<> "/api/download?root=personal&path=streaming-large.bin"
|
{ optionCookies = Just (sessionCookies session) }
|
||||||
]
|
preservedPath
|
||||||
assertEqual "preserved object status" 200 (curlStatus preserved)
|
(probeBase context
|
||||||
|
<> "/api/download?root=personal&path=streaming-large.bin")
|
||||||
|
assertEqual "preserved object status" 200 (probeStatus preserved)
|
||||||
hashFile preservedPath >>= assertEqual "preserved object digest" largeDigest
|
hashFile preservedPath >>= assertEqual "preserved object digest" largeDigest
|
||||||
let partialPath = directory <> "/partial-download.bin"
|
let partialPath = directory <> "/partial-download.bin"
|
||||||
partial <- interruptedDownload context session partialPath
|
partial <- interruptedDownload context session partialPath
|
||||||
|
|
@ -128,9 +133,9 @@ verifyLarge context session directory = do
|
||||||
assert "aborted download emitted no bytes" (partialBytes > 0)
|
assert "aborted download emitted no bytes" (partialBytes > 0)
|
||||||
assert "aborted download emitted the complete object" $
|
assert "aborted download emitted the complete object" $
|
||||||
partialBytes < largeBytes
|
partialBytes < largeBytes
|
||||||
ready <- curl [probeBase context <> "/readyz"]
|
ready <- get (probeClient context) (probeBase context <> "/readyz")
|
||||||
requireStatus "readiness after aborts" 200 ready
|
requireStatus "readiness after aborts" 200 ready
|
||||||
assertContains "readiness after aborts" "\"ready\":true" (curlBody ready)
|
assertContains "readiness after aborts" "\"ready\":true" (probeBody ready)
|
||||||
putStrLn "Large streaming and abort behavior passed"
|
putStrLn "Large streaming and abort behavior passed"
|
||||||
|
|
||||||
verifyThrottle :: ProbeContext -> Session -> FilePath -> IO ()
|
verifyThrottle :: ProbeContext -> Session -> FilePath -> IO ()
|
||||||
|
|
@ -141,35 +146,42 @@ verifyThrottle context session directory = do
|
||||||
throttlePath = directory <> "/throttle-upload.bin"
|
throttlePath = directory <> "/throttle-upload.bin"
|
||||||
writeZeros jointPath 65536
|
writeZeros jointPath 65536
|
||||||
jointDigest <- hashFile jointPath
|
jointDigest <- hashFile jointPath
|
||||||
joint <- uploadFileDeclared
|
joint <- curlUploadFileDeclared
|
||||||
context session "joint-throttle.bin"
|
context session "joint-throttle.bin"
|
||||||
"70000000-0000-4000-8000-000000000013"
|
"70000000-0000-4000-8000-000000000013"
|
||||||
65536 jointDigest jointPath []
|
65536 jointDigest jointPath
|
||||||
requireSucceeded "joint throttle setup" joint
|
requireSucceeded "joint throttle setup" joint
|
||||||
writeZeros throttlePath 1048576
|
writeZeros throttlePath 1048576
|
||||||
uploadDigest <- hashFile throttlePath
|
uploadDigest <- hashFile throttlePath
|
||||||
throttled <- uploadFileDeclared
|
throttled <- curlUploadFileDeclared
|
||||||
context sessionB "throttled-upload.bin"
|
context sessionB "throttled-upload.bin"
|
||||||
"71000000-0000-4000-8000-000000000001"
|
"71000000-0000-4000-8000-000000000001"
|
||||||
1048576 uploadDigest throttlePath []
|
1048576 uploadDigest throttlePath
|
||||||
requireError "incremental upload throttle"
|
requireError "incremental upload throttle"
|
||||||
429 "bandwidth_exceeded" throttled
|
429 "bandwidth_exceeded" throttled
|
||||||
prestart <- uploadBytesDeclared
|
prestart <- curlUploadBytesDeclared
|
||||||
context sessionB "prestart.bin"
|
context sessionB "prestart.bin"
|
||||||
"71000000-0000-4000-8000-000000000002"
|
"71000000-0000-4000-8000-000000000002"
|
||||||
1 uploadDigest "x" []
|
1 uploadDigest "x"
|
||||||
assertEqual "pre-start upload throttle" 429 (curlStatus prestart)
|
assertEqual
|
||||||
|
("pre-start upload throttle: " <> B8.unpack (probeBody prestart))
|
||||||
|
429
|
||||||
|
(probeStatus prestart)
|
||||||
let firstPath = directory <> "/parallel-a"
|
let firstPath = directory <> "/parallel-a"
|
||||||
secondPath = directory <> "/parallel-b"
|
secondPath = directory <> "/parallel-b"
|
||||||
downloadTo path =
|
downloadAttempt path = do
|
||||||
try (curlToFile path
|
cookies <- cookieHeader (sessionCookies session)
|
||||||
[ "--cookie", sessionCookies session
|
try
|
||||||
, probeBase context
|
(curlToFile path
|
||||||
<> "/api/download?root=personal&path=streaming-large.bin"
|
[ "--header", "Cookie: " <> B8.unpack cookies
|
||||||
]) :: IO (Either SomeException CurlResponse)
|
, probeBase context
|
||||||
|
<> "/api/download?root=personal&path=streaming-large.bin"
|
||||||
|
])
|
||||||
|
:: IO (Either SomeException ProbeResponse)
|
||||||
BS.writeFile firstPath BS.empty
|
BS.writeFile firstPath BS.empty
|
||||||
BS.writeFile secondPath BS.empty
|
BS.writeFile secondPath BS.empty
|
||||||
(first, second) <- concurrently (downloadTo firstPath) (downloadTo secondPath)
|
(first, second) <-
|
||||||
|
concurrently (downloadAttempt firstPath) (downloadAttempt secondPath)
|
||||||
assert "first throttled download completed" (isLeft first)
|
assert "first throttled download completed" (isLeft first)
|
||||||
assert "second throttled download completed" (isLeft second)
|
assert "second throttled download completed" (isLeft second)
|
||||||
firstBytes <- getFileSize firstPath
|
firstBytes <- getFileSize firstPath
|
||||||
|
|
@ -189,21 +201,21 @@ verifyRefill context session directory = do
|
||||||
let bodyPath = directory <> "/refill-upload.bin"
|
let bodyPath = directory <> "/refill-upload.bin"
|
||||||
writeZeros bodyPath 65536
|
writeZeros bodyPath 65536
|
||||||
digest <- hashFile bodyPath
|
digest <- hashFile bodyPath
|
||||||
initial <- uploadFileDeclared
|
initial <- curlUploadFileDeclared
|
||||||
context session "refill-initial.bin"
|
context session "refill-initial.bin"
|
||||||
"72000000-0000-4000-8000-000000000001"
|
"72000000-0000-4000-8000-000000000001"
|
||||||
65536 digest bodyPath []
|
65536 digest bodyPath
|
||||||
requireSucceeded "initial token use" initial
|
requireSucceeded "initial token use" initial
|
||||||
exhausted <- uploadFileDeclared
|
exhausted <- curlUploadFileDeclared
|
||||||
context session "refill-exhausted.bin"
|
context session "refill-exhausted.bin"
|
||||||
"72000000-0000-4000-8000-000000000003"
|
"72000000-0000-4000-8000-000000000003"
|
||||||
65536 digest bodyPath []
|
65536 digest bodyPath
|
||||||
requireStatus "exhausted token bucket" 429 exhausted
|
requireStatus "exhausted token bucket" 429 exhausted
|
||||||
threadDelay 2000000
|
threadDelay 2000000
|
||||||
refilled <- uploadFileDeclared
|
refilled <- curlUploadFileDeclared
|
||||||
context session "refill-refilled.bin"
|
context session "refill-refilled.bin"
|
||||||
"72000000-0000-4000-8000-000000000002"
|
"72000000-0000-4000-8000-000000000002"
|
||||||
65536 digest bodyPath []
|
65536 digest bodyPath
|
||||||
requireSucceeded "refilled token bucket" refilled
|
requireSucceeded "refilled token bucket" refilled
|
||||||
putStrLn "Token-bucket refill behavior passed"
|
putStrLn "Token-bucket refill behavior passed"
|
||||||
|
|
||||||
|
|
@ -211,15 +223,15 @@ verifyUnavailable :: ProbeContext -> Session -> IO ()
|
||||||
verifyUnavailable context session = do
|
verifyUnavailable context session = do
|
||||||
listing <- browse context session "?root=personal"
|
listing <- browse context session "?root=personal"
|
||||||
requireStatus "browse without Redis" 200 listing
|
requireStatus "browse without Redis" 200 listing
|
||||||
assertContains "browse without Redis" "\"path\":" (curlBody listing)
|
assertContains "browse without Redis" "\"path\":" (probeBody listing)
|
||||||
let path =
|
let path =
|
||||||
if "cross-user.txt" `BS.isInfixOf` curlBody listing
|
if "cross-user.txt" `BS.isInfixOf` probeBody listing
|
||||||
then "cross-user.txt"
|
then "cross-user.txt"
|
||||||
else "streaming-large.bin"
|
else "streaming-large.bin"
|
||||||
query =
|
query =
|
||||||
if path == "cross-user.txt" then "CROSS-USER" else "STREAMING-LARGE"
|
if path == "cross-user.txt" then "CROSS-USER" else "STREAMING-LARGE"
|
||||||
searched <- browse context session ("?root=personal&q=" <> query)
|
searched <- browse context session ("?root=personal&q=" <> query)
|
||||||
assertContains "search without Redis" (B8.pack path) (curlBody searched)
|
assertContains "search without Redis" (B8.pack path) (probeBody searched)
|
||||||
unavailableUpload <- upload context session "personal" "unavailable.bin"
|
unavailableUpload <- upload context session "personal" "unavailable.bin"
|
||||||
"72000000-0000-4000-8000-000000000001" "x"
|
"72000000-0000-4000-8000-000000000001" "x"
|
||||||
requireError "upload without Redis"
|
requireError "upload without Redis"
|
||||||
|
|
@ -249,14 +261,22 @@ uploadFileDeclared
|
||||||
-> Integer
|
-> Integer
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> FilePath
|
-> FilePath
|
||||||
-> [String]
|
-> RequestOptions
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
uploadFileDeclared context session path operationId size digest bodyPath extra =
|
uploadFileDeclared context session path operationId size digest bodyPath options =
|
||||||
curl
|
postWith
|
||||||
( extra
|
(probeClient context)
|
||||||
<> uploadArguments context session path operationId size digest
|
options
|
||||||
<> ["--data-binary", "@" <> bodyPath]
|
{ optionCookies = Just (sessionCookies session)
|
||||||
)
|
, optionHeaders =
|
||||||
|
[ ("Content-Type", "application/octet-stream")
|
||||||
|
, ("X-Declared-Size", B8.pack (show size))
|
||||||
|
, ("X-Content-SHA256", digest)
|
||||||
|
, ("X-CSRF-Token", sessionCsrf session)
|
||||||
|
] <> optionHeaders options
|
||||||
|
, optionBody = FileBody bodyPath
|
||||||
|
}
|
||||||
|
(uploadUrl context path operationId)
|
||||||
|
|
||||||
uploadBytesDeclared
|
uploadBytesDeclared
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
|
|
@ -266,14 +286,22 @@ uploadBytesDeclared
|
||||||
-> Integer
|
-> Integer
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> [String]
|
-> RequestOptions
|
||||||
-> IO CurlResponse
|
-> IO ProbeResponse
|
||||||
uploadBytesDeclared context session path operationId size digest body extra =
|
uploadBytesDeclared context session path operationId size digest body options =
|
||||||
withSystemTempFile "nuthatch-streaming-body" $ \bodyPath handle -> do
|
postWith
|
||||||
BS.hPut handle body
|
(probeClient context)
|
||||||
hClose handle
|
options
|
||||||
uploadFileDeclared
|
{ optionCookies = Just (sessionCookies session)
|
||||||
context session path operationId size digest bodyPath extra
|
, optionHeaders =
|
||||||
|
[ ("Content-Type", "application/octet-stream")
|
||||||
|
, ("X-Declared-Size", B8.pack (show size))
|
||||||
|
, ("X-Content-SHA256", digest)
|
||||||
|
, ("X-CSRF-Token", sessionCsrf session)
|
||||||
|
] <> optionHeaders options
|
||||||
|
, optionBody = ByteBody body
|
||||||
|
}
|
||||||
|
(uploadUrl context path operationId)
|
||||||
|
|
||||||
uploadArguments
|
uploadArguments
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
|
|
@ -282,19 +310,50 @@ uploadArguments
|
||||||
-> String
|
-> String
|
||||||
-> Integer
|
-> Integer
|
||||||
-> BS.ByteString
|
-> BS.ByteString
|
||||||
-> [String]
|
-> IO [String]
|
||||||
uploadArguments context session path operationId size digest =
|
uploadArguments context session path operationId size digest = do
|
||||||
[ "--cookie", sessionCookies session
|
cookies <- cookieHeader (sessionCookies session)
|
||||||
, "--header", "Content-Type: application/octet-stream"
|
pure
|
||||||
, "--header", "X-Declared-Size: " <> show size
|
[ "--header", "Cookie: " <> B8.unpack cookies
|
||||||
, "--header", "X-Content-SHA256: " <> B8.unpack digest
|
, "--header", "Content-Type: application/octet-stream"
|
||||||
, "--header", "X-CSRF-Token: " <> B8.unpack (sessionCsrf session)
|
, "--header", "X-Declared-Size: " <> show size
|
||||||
, probeBase context
|
, "--header", "X-Content-SHA256: " <> B8.unpack digest
|
||||||
<> "/api/upload?root=personal&path="
|
, "--header", "X-CSRF-Token: " <> B8.unpack (sessionCsrf session)
|
||||||
<> path
|
, probeBase context
|
||||||
<> "&operation_id="
|
<> "/api/upload?root=personal&path="
|
||||||
<> operationId
|
<> path
|
||||||
]
|
<> "&operation_id="
|
||||||
|
<> operationId
|
||||||
|
]
|
||||||
|
|
||||||
|
curlUploadFileDeclared
|
||||||
|
:: ProbeContext
|
||||||
|
-> Session
|
||||||
|
-> String
|
||||||
|
-> String
|
||||||
|
-> Integer
|
||||||
|
-> BS.ByteString
|
||||||
|
-> FilePath
|
||||||
|
-> IO ProbeResponse
|
||||||
|
curlUploadFileDeclared context session path operationId size digest bodyPath = do
|
||||||
|
arguments <- uploadArguments context session path operationId size digest
|
||||||
|
curl (arguments <> ["--data-binary", "@" <> bodyPath])
|
||||||
|
|
||||||
|
curlUploadBytesDeclared
|
||||||
|
:: ProbeContext
|
||||||
|
-> Session
|
||||||
|
-> String
|
||||||
|
-> String
|
||||||
|
-> Integer
|
||||||
|
-> BS.ByteString
|
||||||
|
-> BS.ByteString
|
||||||
|
-> IO ProbeResponse
|
||||||
|
curlUploadBytesDeclared context session path operationId size digest body =
|
||||||
|
withSystemTempFile "nuthatch-streaming-body" $ \bodyPath handle -> do
|
||||||
|
BS.hPut handle body
|
||||||
|
hClose handle
|
||||||
|
curlUploadFileDeclared
|
||||||
|
context session path operationId size digest bodyPath
|
||||||
|
|
||||||
interruptedUpload
|
interruptedUpload
|
||||||
:: ProbeContext
|
:: ProbeContext
|
||||||
|
|
@ -306,6 +365,7 @@ interruptedUpload
|
||||||
-> FilePath
|
-> FilePath
|
||||||
-> IO Bool
|
-> IO Bool
|
||||||
interruptedUpload context session path operationId size digest bodyPath = do
|
interruptedUpload context session path operationId size digest bodyPath = do
|
||||||
|
common <- uploadArguments context session path operationId size digest
|
||||||
let arguments =
|
let arguments =
|
||||||
[ "--insecure"
|
[ "--insecure"
|
||||||
, "--silent"
|
, "--silent"
|
||||||
|
|
@ -315,7 +375,7 @@ interruptedUpload context session path operationId size digest bodyPath = do
|
||||||
, "--request", "POST"
|
, "--request", "POST"
|
||||||
, "--upload-file", bodyPath
|
, "--upload-file", bodyPath
|
||||||
]
|
]
|
||||||
<> uploadArguments context session path operationId size digest
|
<> common
|
||||||
(exitCode, _, _) <- readProcessWithExitCode "curl" arguments ""
|
(exitCode, _, _) <- readProcessWithExitCode "curl" arguments ""
|
||||||
pure (exitCode == ExitSuccess)
|
pure (exitCode == ExitSuccess)
|
||||||
|
|
||||||
|
|
@ -325,13 +385,14 @@ interruptedDownload
|
||||||
-> FilePath
|
-> FilePath
|
||||||
-> IO Bool
|
-> IO Bool
|
||||||
interruptedDownload context session outputPath = do
|
interruptedDownload context session outputPath = do
|
||||||
|
cookies <- cookieHeader (sessionCookies session)
|
||||||
let arguments =
|
let arguments =
|
||||||
[ "--insecure"
|
[ "--insecure"
|
||||||
, "--silent"
|
, "--silent"
|
||||||
, "--show-error"
|
, "--show-error"
|
||||||
, "--max-time", "1"
|
, "--max-time", "1"
|
||||||
, "--limit-rate", "64k"
|
, "--limit-rate", "64k"
|
||||||
, "--cookie", sessionCookies session
|
, "--header", "Cookie: " <> B8.unpack cookies
|
||||||
, "--output", outputPath
|
, "--output", outputPath
|
||||||
, probeBase context
|
, probeBase context
|
||||||
<> "/api/download?root=personal&path=streaming-large.bin"
|
<> "/api/download?root=personal&path=streaming-large.bin"
|
||||||
|
|
@ -339,6 +400,14 @@ interruptedDownload context session outputPath = do
|
||||||
(exitCode, _, _) <- readProcessWithExitCode "curl" arguments ""
|
(exitCode, _, _) <- readProcessWithExitCode "curl" arguments ""
|
||||||
pure (exitCode == ExitSuccess)
|
pure (exitCode == ExitSuccess)
|
||||||
|
|
||||||
|
uploadUrl :: ProbeContext -> String -> String -> String
|
||||||
|
uploadUrl context path operationId =
|
||||||
|
probeBase context
|
||||||
|
<> "/api/upload?root=personal&path="
|
||||||
|
<> path
|
||||||
|
<> "&operation_id="
|
||||||
|
<> operationId
|
||||||
|
|
||||||
writeZeros :: FilePath -> Integer -> IO ()
|
writeZeros :: FilePath -> Integer -> IO ()
|
||||||
writeZeros path byteCount =
|
writeZeros path byteCount =
|
||||||
withBinaryFile path WriteMode $ \handle -> do
|
withBinaryFile path WriteMode $ \handle -> do
|
||||||
|
|
@ -369,19 +438,19 @@ readEnvInteger name = do
|
||||||
value <- requireEnv name
|
value <- requireEnv name
|
||||||
maybe (fail ("invalid " <> name)) pure (readMaybe value)
|
maybe (fail ("invalid " <> name)) pure (readMaybe value)
|
||||||
|
|
||||||
requireSucceeded :: String -> CurlResponse -> IO ()
|
requireSucceeded :: String -> ProbeResponse -> IO ()
|
||||||
requireSucceeded label response = do
|
requireSucceeded label response = do
|
||||||
requireStatus label 200 response
|
requireStatus label 200 response
|
||||||
assertContains label "\"result\":\"succeeded\"" (curlBody 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
|
requireError label status code response = do
|
||||||
requireStatus label status response
|
requireStatus label status response
|
||||||
assertContains label ("\"error\":\"" <> code <> "\"") (curlBody response)
|
assertContains label ("\"error\":\"" <> code <> "\"") (probeBody response)
|
||||||
|
|
||||||
requireStatus :: String -> Int -> CurlResponse -> IO ()
|
requireStatus :: String -> Int -> ProbeResponse -> IO ()
|
||||||
requireStatus label expected response =
|
requireStatus label expected response =
|
||||||
assertEqual (label <> " status") expected (curlStatus response)
|
assertEqual (label <> " status") expected (probeStatus response)
|
||||||
|
|
||||||
isLeft :: Either left right -> Bool
|
isLeft :: Either left right -> Bool
|
||||||
isLeft (Left _) = True
|
isLeft (Left _) = True
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue