notion-client-0.10.0.0: tasty/OAuthTests.hs
-- | Tests for the OAuth endpoints.
module OAuthTests (tests, tokenJson, personOwner, credentials) where
import Control.Exception (SomeException, displayException, fromException, throwIO, try)
import Control.Monad (forM_)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy.Char8 qualified as L8
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.List (isInfixOf)
import FakeNotion
import Notion.V1 (defaultClientConfig)
import Notion.V1.Client (ClientConfig (..), LogLevel (..))
import Notion.V1.Error qualified as E
import Notion.V1.OAuth
import Servant.Client (ClientEnv (..))
import Test.Tasty
import Test.Tasty.HUnit
tests :: TestTree
tests =
testGroup
"OAuth"
( errorTests
<> [ testCase "basicAuthorization encodes client_id:client_secret" $
basicAuthorization credentials @?= "Basic Y2xpZW50OnNlY3JldA==",
testCase "authorization_code request encodes" $ do
let grant = AuthorizationCodeGrant "code-123" (Just "https://example.com/callback") Nothing
Aeson.toJSON (AuthorizationCode grant)
@?= Aeson.object
[ "grant_type" Aeson..= ("authorization_code" :: String),
"code" Aeson..= ("code-123" :: String),
"redirect_uri" Aeson..= ("https://example.com/callback" :: String)
]
Aeson.toJSON (AuthorizationCode grant {externalAccount = Just (ExternalAccount "acct-1" "Tanaka Hanako")})
@?= Aeson.object
[ "grant_type" Aeson..= ("authorization_code" :: String),
"code" Aeson..= ("code-123" :: String),
"redirect_uri" Aeson..= ("https://example.com/callback" :: String),
"external_account" Aeson..= Aeson.object ["key" Aeson..= ("acct-1" :: String), "name" Aeson..= ("Tanaka Hanako" :: String)]
],
testCase "refresh_token request encodes" $
Aeson.toJSON (RefreshToken "nrt_abc")
@?= Aeson.object ["grant_type" Aeson..= ("refresh_token" :: String), "refresh_token" Aeson..= ("nrt_abc" :: String)],
testCase "token response with person owner decodes" $ do
OAuthTokenResponse {owner, refreshToken} <- decodeOrFail (tokenJson personOwner)
refreshToken @?= Just "nrt_tanaka_refresh"
case owner of
OAuthUserOwner OAuthOwnerUser {email, name} -> do
email @?= Just "hanako@example.com"
name @?= Just "Tanaka Hanako"
other -> assertFailure ("expected a user owner, got " <> show other),
testCase "partial user owner and workspace owner decode" $ do
OAuthTokenResponse {owner = partial} <-
decodeOrFail (tokenJson "{\"type\":\"user\",\"user\":{\"id\":\"0e1d2c3b-4a59-4687-b7a6-958473625140\",\"object\":\"user\"}}")
case partial of
OAuthUserOwner OAuthOwnerUser {email, type_} -> do
email @?= Nothing
type_ @?= Nothing
other -> assertFailure ("expected a user owner, got " <> show other)
OAuthTokenResponse {owner = workspace} <- decodeOrFail (tokenJson "{\"type\":\"workspace\",\"workspace\":true}")
workspace @?= OAuthWorkspaceOwner
OAuthTokenResponse {owner = team} <- decodeOrFail (tokenJson "{\"type\":\"team\"}")
case team of
UnknownOAuthOwner _ -> pure ()
other -> assertFailure ("expected UnknownOAuthOwner, got " <> show other),
testCase "introspect response decodes" $ do
r1 <- decodeOrFail "{\"active\":true,\"scope\":\"read_content\",\"iat\":1757890000,\"request_id\":\"r-1\"}"
r1 @?= OAuthIntrospectResponse True (Just "read_content") (Just 1757890000) (Just "r-1")
r2 <- decodeOrFail "{\"active\":false}"
r2 @?= OAuthIntrospectResponse False Nothing Nothing Nothing,
testCase "createOAuthToken sends Basic auth to POST /oauth/token" $ do
(env, recorded) <- fakeClientEnv [jsonReply 200 (tokenJson personOwner)]
let oauth = makeOAuthMethodsWith defaultClientConfig env credentials
_ <- createOAuthToken oauth (RefreshToken "nrt_tanaka_refresh")
readIORef recorded >>= \case
[r@Recorded {method, path}] -> do
method @?= "POST"
path @?= "/oauth/token"
lookupRecordedHeader "Authorization" r @?= Just "Basic Y2xpZW50OnNlY3JldA=="
length [() | ("Authorization", _) <- headers r] @?= 1
rs -> assertFailure ("expected one request, got " <> show (length rs))
]
)
credentials :: OAuthCredentials
credentials = OAuthCredentials {clientId = "client", clientSecret = "secret"}
decodeOrFail :: (Aeson.FromJSON a) => L8.ByteString -> IO a
decodeOrFail bytes = either (assertFailure . ("decode failed: " <>)) pure (Aeson.eitherDecode bytes)
personOwner :: L8.ByteString
personOwner =
"{\"type\":\"user\",\"user\":{\"type\":\"person\",\"person\":{\"email\":\"hanako@example.com\"},\"name\":\"Tanaka Hanako\",\"avatar_url\":null,\"id\":\"0e1d2c3b-4a59-4687-b7a6-958473625140\",\"object\":\"user\"}}"
tokenJson :: L8.ByteString -> L8.ByteString
tokenJson owner =
"{\"access_token\":\"secret_tanaka_access\",\"token_type\":\"bearer\",\"refresh_token\":\"nrt_tanaka_refresh\",\"bot_id\":\"2f8e6d4c-1b3a-4c5d-8e7f-9a0b1c2d3e4f\",\"workspace_icon\":null,\"workspace_name\":\"Tanaka Hanako's Workspace\",\"workspace_id\":\"7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d\",\"owner\":"
<> owner
<> ",\"duplicated_template_id\":null,\"request_id\":\"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\"}"
errorTests :: [TestTree]
errorTests =
[ testCase "OAuth timeout propagates with one attempt" $ do
(env, _) <- fakeClientEnv []
calls <- newIORef (0 :: Int)
let failing = env {middleware = \_ _ -> liftIO (modifyIORef' calls (+ 1) >> throwIO E.RequestTimeoutError)}
result <- try @E.RequestTimeoutError (createOAuthToken (makeOAuthMethodsWith defaultClientConfig failing credentials) (RefreshToken "secret"))
assertBool "timeout" (either (const True) (const False) result)
readIORef calls >>= (@?= 1),
testCase "OAuth errors retain metadata and redact secrets" $ do
(env, recorded) <- fakeClientEnv [FakeReply 400 [("Content-Type", "application/json"), ("x-notion-request-id", "r-oauth-2"), ("Secret", "SENTINEL")] "{\"error\":\"invalid_grant\",\"error_description\":\"SENTINEL\"}"]
logs <- newIORef []
let config = defaultClientConfig {logger = Just (\_ msg fields -> modifyIORef' logs (<> [show (msg, fields)])), logLevel = LogDebug}
r <- try @OAuthError (createOAuthToken (makeOAuthMethodsWith config env credentials) (RefreshToken "SENTINEL"))
case r of
Left e@OAuthError {oauthErrorCode, errorDescription, requestId, response} -> do
oauthErrorCode @?= OAuthInvalidGrant
errorDescription @?= Just "SENTINEL"
requestId @?= Just "r-oauth-2"
fmap E.httpStatus response @?= Just 400
assertBool "show redacted" (not ("SENTINEL" `isInfixOf` (show e <> displayException e)))
Right _ -> assertFailure "expected OAuthError"
readIORef logs >>= assertBool "debug logs redacted" . all (not . isInfixOf "SENTINEL")
length <$> readIORef recorded >>= (@?= 1),
testCase "OAuth and documented Notion envelopes normalize locally" $ do
forM_
[ ("{\"error\":\"invalid_client\",\"request_id\":\"body-id\"}", OAuthInvalidClient),
("{\"object\":\"error\",\"code\":\"invalid_grant\",\"message\":\"bad\"}", OAuthInvalidGrant),
("{\"error\":\"slow_down\"}", UnknownOAuthErrorCode "slow_down")
]
$ \(body, expected) -> do
(env, _) <- fakeClientEnv [jsonReply 401 body]
result <- try @OAuthError (revokeOAuthToken (makeOAuthMethodsWith defaultClientConfig env credentials) "token")
case result of
Left OAuthError {oauthErrorCode, response} -> do
oauthErrorCode @?= expected
fmap E.httpStatus response @?= Just 401
Right _ -> assertFailure "expected OAuthError",
testCase "ordinary and malformed errors retain original exception" $ do
forM_ ["validation_error", "brand_new_code"] $ \c -> do
(env, _) <- fakeClientEnv [jsonReply 400 ("{\"object\":\"error\",\"code\":\"" <> c <> "\",\"message\":\"bad\"}")]
result <- try @E.NotionError (revokeOAuthToken (makeOAuthMethodsWith defaultClientConfig env credentials) "token")
assertBool "NotionError" (either (const True) (const False) result)
forM_ ["oops", "{\"error\":\"\"}", "{\"error\":17}", "{}"] $ \body -> do
(env, _) <- fakeClientEnv [jsonReply 502 body]
r <- try @E.UnknownHTTPResponseError (introspectOAuthToken (makeOAuthMethodsWith defaultClientConfig env credentials) "token")
assertBool "UnknownHTTPResponseError" (either (const True) (const False) r),
testCase "OAuth never retries grants revoke or introspect" $ do
forM_ ["rate_limited", "service_overload"] $ \code ->
forM_
[ \o -> () <$ createOAuthToken o (AuthorizationCode (AuthorizationCodeGrant "c" Nothing Nothing)),
\o -> () <$ createOAuthToken o (RefreshToken "r"),
\o -> () <$ revokeOAuthToken o "t",
\o -> () <$ introspectOAuthToken o "t"
]
$ \call -> do
(env, recorded) <- fakeClientEnv [jsonReply 429 ("{\"object\":\"error\",\"code\":\"" <> code <> "\",\"message\":\"bad\"}")]
r <- try @SomeException (call (makeOAuthMethodsWith defaultClientConfig env credentials))
assertBool "original NotionError" (either (maybe False (const True) . fromException @E.NotionError) (const False) r)
length <$> readIORef recorded >>= (@?= 1),
testCase "all known codes and JSON roundtrip" $
forM_ [OAuthInvalidRequest, OAuthInvalidClient, OAuthInvalidGrant, OAuthUnauthorizedClient, OAuthUnsupportedGrantType, OAuthInvalidScope, OAuthAccessDenied, OAuthUnsupportedResponseType, OAuthServerError, OAuthTemporarilyUnavailable, UnknownOAuthErrorCode "future"] $ \c -> do
parseOAuthErrorCode (oauthErrorCodeText c) @?= c
Aeson.decode (Aeson.encode c) @?= Just c
]