packages feed

notion-client-0.10.0.0: tasty/TokenHTTPTests.hs

module TokenHTTPTests (tests) where

import Control.Exception
import Control.Monad (void)
import Control.Monad.IO.Class qualified
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as L
import Data.Foldable qualified
import Data.IORef
import Network.HTTP.Client qualified as H
import Network.HTTP.Types qualified as T
import Network.Wai qualified as W
import Network.Wai.Handler.Warp qualified as Warp
import Notion.V1
import Notion.V1.Client (applyTimeout)
import Notion.V1.Error
import Notion.V1.FileUploads (mkSendFileUpload)
import Notion.V1.Search qualified
import Servant.Client qualified as S
import Servant.Client.Core qualified
import System.Directory (getTemporaryDirectory, removeFile)
import System.IO
import Test.Tasty
import Test.Tasty.HUnit
import TokenSourceTests (bounded)

rejection :: L.ByteString
rejection = "{\"object\":\"error\",\"code\":\"unauthorized\",\"message\":\"invalid token\"}"

tests :: TestTree
tests =
  testGroup
    "TokenSource HTTP"
    [ testCase "real acquisition closes rejection before renewal; opened callbacks never replay" $ bounded $ do
        requests <- newIORef []
        closed <- newIORef False
        renewals <- newIORef (0 :: Int)
        let app req respond = do
              atomicModifyIORef' requests (\rs -> (rs <> [W.requestHeaders req], ()))
              respond $
                if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old"
                  then W.responseLBS T.status401 [("Content-Type", "application/json")] rejection
                  else W.responseLBS T.status200 [("Content-Type", "text/event-stream")] "data: ok\n\n"
        Warp.testWithApplication (pure app) $ \port -> do
          manager <- H.newManager H.defaultManagerSettings
          let base = S.BaseUrl S.Http "127.0.0.1" port "/v1"
              source =
                TokenSource
                  (pure "old")
                  ( \_ -> do
                      readIORef closed >>= assertBool "rejected response already closed"
                      modifyIORef' renewals (+ 1)
                      pure (Just "new")
                  )
              context = requestContextWithTokenSource defaultClientConfig (S.mkClientEnv manager base) source
          request <- H.parseRequest (S.showBaseUrl base <> "/sessions")
          let acquire = withStandardHeaders context $ \headers -> withRetries defaultClientConfig "POST" "sessions.stream" $ mask $ \restore -> do
                response <- restore (H.responseOpen (applyTimeout defaultClientConfig request {H.requestHeaders = headers, H.method = "POST"}) manager)
                if T.statusIsSuccessful (H.responseStatus response)
                  then pure response
                  else do
                    chunks <- restore (H.brConsume (H.responseBody response)) `finally` (H.responseClose response >> writeIORef closed True)
                    throwIO (notionErrorFromResponse (H.responseStatus response) (H.responseHeaders response) (L.fromChunks chunks))
          outcome <- try @NotionError $ bracket acquire H.responseClose $ \res -> do
            chunk <- H.brRead (H.responseBody res)
            assertBool "stream opened" (not (BS.null chunk))
            throwIO (NotionError "error" 401 Unauthorized "callback failed" Nothing Nothing Nothing Nothing)
          either (\e -> code e @?= Unauthorized) (const (assertFailure "callback should throw")) outcome
          readIORef renewals >>= (@?= 1)
          hs <- readIORef requests
          map (lookup "Authorization") hs @?= [Just "Bearer old", Just "Bearer new"]
          map (lookup "Notion-Version") hs @?= replicate 2 (Just "2026-03-11")
          assertBool "user agent on every open" (all ((/= Nothing) . lookup "User-Agent") hs),
      testCase "caller middleware and request builder retained; bytes replay identically" $ bounded $ do
        received <- newIORef []
        middle <- newIORef []
        built <- newIORef (0 :: Int)
        renewals <- newIORef (0 :: Int)
        let app req respond = do
              body <- W.strictRequestBody req
              atomicModifyIORef' received (\rs -> (rs <> [(W.requestHeaders req, body)], ()))
              respond $
                W.responseLBS
                  (if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old" then T.status401 else T.status200)
                  [("Content-Type", "application/json")]
                  (if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old" then rejection else "{\"object\":\"list\",\"results\":[],\"has_more\":false,\"next_cursor\":null}")
        Warp.testWithApplication (pure app) $ \port -> do
          manager <- H.newManager H.defaultManagerSettings
          let env = S.mkClientEnv manager (S.BaseUrl S.Http "127.0.0.1" port "/v1")
              custom =
                env
                  { S.makeClientRequest = \base req -> modifyIORef' built (+ 1) >> S.makeClientRequest env base req,
                    S.middleware = \next req -> do
                      Control.Monad.IO.Class.liftIO $ modifyIORef' middle (<> [Data.Foldable.toList (Servant.Client.Core.requestHeaders req)])
                      next req
                  }
              source = TokenSource (pure "old") (\_ -> modifyIORef' renewals (+ 1) >> pure (Just "new"))
          void (search (makeMethodsWithTokenSource defaultClientConfig custom source) (Notion.V1.Search.SearchRequest Nothing Nothing Nothing Nothing Nothing))
          rs <- readIORef received
          case rs of
            [(_, a), (_, b)] -> a @?= b
            _ -> assertFailure "expected two byte requests"
          length <$> readIORef middle >>= (@?= 2)
          readIORef middle >>= mapM_ (\hs -> length (filter ((== "Authorization") . fst) hs) @?= 1)
          readIORef built >>= (@?= 2)
          readIORef renewals >>= (@?= 1),
      testCase "file-backed multipart body can be replayed after rejection" $
        bounded $
          bracket (getTemporaryDirectory >>= \directory -> openBinaryTempFile directory "notion-upload") (\(path, h) -> hClose h `finally` removeFile path) $ \(path, h) -> do
            BS.hPut h "fixture bytes: Tanaka Hanako"
            hFlush h
            hClose h
            received <- newIORef []
            let app req respond = do
                  body <- W.strictRequestBody req
                  atomicModifyIORef' received (\rs -> (rs <> [body], ()))
                  respond $
                    W.responseLBS
                      (if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old" then T.status401 else T.status200)
                      [("Content-Type", "application/json")]
                      (if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old" then rejection else "{}")
            Warp.testWithApplication (pure app) $ \port -> do
              manager <- H.newManager H.defaultManagerSettings
              let env = S.mkClientEnv manager (S.BaseUrl S.Http "127.0.0.1" port "/v1")
                  source = TokenSource (pure "old") (const (pure (Just "new")))
              -- The deliberately partial success fixture fails decoding after acquisition.
              void (try @S.ClientError (sendFileUploadContent (makeMethodsWithTokenSource defaultClientConfig env source) "upload-id" (mkSendFileUpload path "hanako.txt" "text/plain")))
              readIORef received >>= \case
                [a, b] -> do
                  a @?= b
                  assertBool "file bytes present" ("fixture bytes: Tanaka Hanako" `BS.isInfixOf` L.toStrict b)
                _ -> assertFailure "expected multipart replay"
    ]