packages feed

notion-client-0.10.0.0: tasty/OAuthExampleTests.hs

{-# LANGUAGE GHC2024 #-}

module OAuthExampleTests (tests) where

import Control.Concurrent
import Control.Exception
import Data.Aeson qualified as A
import Data.Bits ((.&.))
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as L
import Data.IORef
import Data.Time.Clock
import FileTokenStore
import Login
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.OAuth qualified as O
import Notion.V1.OAuth.Authorization qualified as Auth
import Notion.V1.OAuth.Refresh qualified as R
import OAuthTests (personOwner, tokenJson)
import System.Directory qualified as D
import System.FilePath
import System.Posix.Files qualified as F
import System.Posix.Temp (mkdtemp)
import Test.Tasty
import Test.Tasty.HUnit
import TokenSourceTests (bounded)

request :: Auth.AuthorizationRequest
request = Auth.AuthorizationRequest "client" "http://localhost:8765/callback" (Auth.OAuthState "fixed-state")

fixture :: IO (R.OAuthTokens, O.OAuthTokenResponse)
fixture = do
  response <- either assertFailure pure (A.eitherDecode (tokenJson personOwner))
  now <- getCurrentTime
  pure (R.oauthTokensFromResponse now response, response)

oauth :: IO O.OAuthTokenResponse -> O.OAuthMethods
oauth action = O.OAuthMethods (const action) (const (pure (O.OAuthRevokeResponse Nothing))) (const (pure (O.OAuthIntrospectResponse False Nothing Nothing Nothing)))

get :: H.Manager -> Int -> String -> IO (H.Response L.ByteString)
get manager port query = H.parseRequest ("http://127.0.0.1:" <> show port <> query) >>= \r -> H.httpLbs r manager

withDirectory :: (FilePath -> IO a) -> IO a
withDirectory = bracket (mkdtemp "/tmp/notion-oauth-tests-") D.removePathForcibly

tests :: TestTree
tests =
  testGroup
    "OAuthExample"
    [ testCase "invalid callback leaves login pending; valid callback saves before success" $ bounded $ do
        (tokens, response) <- fixture
        store <- R.inMemoryTokenStore tokens
        calls <- newIORef (0 :: Int)
        let exchange = modifyIORef' calls (+ 1) >> pure response
        (app, result) <- newLoginApplication (oauth exchange) store request "/callback"
        Warp.testWithApplication (pure app) $ \port -> do
          manager <- H.newManager H.defaultManagerSettings
          bad <- get manager port "/callback?code=secret-code&state=forged"
          H.responseStatus bad @?= T.status400
          readIORef calls >>= (@?= 0)
          success <- get manager port "/callback?code=secret-code&state=fixed-state"
          H.responseStatus success @?= T.status200
          H.responseBody success @?= "Connected"
          lookup "Cache-Control" (H.responseHeaders success) @?= Just "no-store"
          lookup "Referrer-Policy" (H.responseHeaders success) @?= Just "no-referrer"
          result >>= \case Connected _ -> pure (); other -> assertFailure (show other)
          readIORef calls >>= (@?= 1)
          duplicate <- get manager port "/callback?code=secret-code&state=fixed-state"
          H.responseStatus duplicate @?= T.status409
          readIORef calls >>= (@?= 1),
      testCase "denial requires state, consumes once, and never exchanges" $ bounded $ do
        (tokens, _) <- fixture
        store <- R.inMemoryTokenStore tokens
        (app, result) <- newLoginApplication (oauth (assertFailure "must not exchange")) store request "/callback"
        Warp.testWithApplication (pure app) $ \port -> do
          manager <- H.newManager H.defaultManagerSettings
          bad <- get manager port "/callback?error=access_denied"
          H.responseStatus bad @?= T.status400
          cancelled <- get manager port "/callback?error=access_denied&state=fixed-state&error_description=%3Cscript%3Esecret%3C%2Fscript%3E"
          H.responseBody cancelled @?= "Consent was cancelled"
          result >>= \case Cancelled -> pure (); other -> assertFailure (show other)
          duplicate <- get manager port "/callback?error=access_denied&state=fixed-state"
          H.responseStatus duplicate @?= T.status409,
      testCase "concurrent duplicates exchange once; no success before save returns" $ bounded $ do
        (tokens, response) <- fixture
        calls <- newIORef (0 :: Int)
        saving <- newEmptyMVar
        release <- newEmptyMVar
        first <- newEmptyMVar
        let store = R.TokenStore (pure tokens) (\_ -> putMVar saving () >> takeMVar release)
        (app, _) <- newLoginApplication (oauth (modifyIORef' calls (+ 1) >> pure response)) store request "/callback"
        Warp.testWithApplication (pure app) $ \port -> do
          manager <- H.newManager H.defaultManagerSettings
          _ <- forkFinally (get manager port "/callback?code=one&state=fixed-state") (putMVar first)
          takeMVar saving
          isEmptyMVar first >>= assertBool "no response before persistence"
          duplicate <- get manager port "/callback?code=two&state=fixed-state"
          H.responseStatus duplicate @?= T.status409
          readIORef calls >>= (@?= 1)
          putMVar release ()
          takeMVar first >>= either (assertFailure . displayException) (\r -> H.responseBody r @?= "Connected"),
      testCase "initial save failure retains credentials for retry without another exchange" $ bounded $ do
        (tokens, response) <- fixture
        broken <- newIORef True
        saved <- newIORef []
        calls <- newIORef (0 :: Int)
        let store =
              R.TokenStore
                (pure tokens)
                ( \pair -> do
                    modifyIORef' saved (<> [pair])
                    readIORef broken >>= \b -> if b then ioError (userError "SECRET") else pure ()
                )
        (app, result) <- newLoginApplication (oauth (modifyIORef' calls (+ 1) >> pure response)) store request "/callback"
        Warp.testWithApplication (pure app) $ \port -> do
          manager <- H.newManager H.defaultManagerSettings
          failed <- get manager port "/callback?code=SECRET&state=fixed-state"
          H.responseStatus failed @?= T.status500
          assertBool "no reflected secret" (not ("SECRET" `BS.isInfixOf` L.toStrict (H.responseBody failed)))
          result >>= \case
            SavePending pair -> do
              show (SavePending pair) @?= "SavePending"
              writeIORef broken False
              R.saveTokens store pair
              readIORef saved >>= (@?= [pair, pair])
            other -> assertFailure (show other)
          readIORef calls >>= (@?= 1),
      testCase "exchange failures and unrelated paths produce generic pages" $ bounded $ do
        (tokens, _) <- fixture
        store <- R.inMemoryTokenStore tokens
        (app, result) <- newLoginApplication (oauth (ioError (userError "SECRET <script>"))) store request "/callback"
        Warp.testWithApplication (pure app) $ \port -> do
          manager <- H.newManager H.defaultManagerSettings
          notFound <- get manager port "/other?code=SECRET&state=fixed-state"
          H.responseStatus notFound @?= T.status404
          failed <- get manager port "/callback?code=SECRET&state=fixed-state"
          H.responseBody failed @?= "Connection failed; start login again"
          result >>= \case LoginFailed -> pure (); other -> assertFailure (show other),
      testCase "startup failure propagates before announcement; timeout closes listener" $ bounded $ do
        (tokens, response) <- fixture
        store <- R.inMemoryTokenStore tokens
        announced <- newIORef False
        let emptyApp _ respond = respond (W.responseLBS T.status200 [] "")
        Warp.testWithApplication (pure emptyApp) $ \busy -> do
          failure <- try @IOException (runLogin busy 20000 (oauth (pure response)) store request (writeIORef announced True))
          assertBool "bind failure" (either (const True) (const False) failure)
          readIORef announced >>= (@?= False)
        free <- Warp.testWithApplication (pure emptyApp) pure
        runLogin free 20000 (oauth (pure response)) store request (writeIORef announced True) >>= \case
          LoginExpired -> pure ()
          other -> assertFailure (show other)
        readIORef announced >>= (@?= True)
        -- Rebinding the same port after timeout proves the listener was released.
        runLogin free 1000 (oauth (pure response)) store request (pure ()) >>= \case
          LoginExpired -> pure ()
          other -> assertFailure (show other),
      testCase "redirect configuration requires a path without query or fragment" $ do
        Auth.oauthStateText (Auth.state request) @?= "fixed-state"
        callbackPath "http://localhost:8765/callback" @?= Right "/callback"
        callbackPath "https://tunnel.example/cb/path" @?= Right "/cb/path"
        mapM_ (\uri -> assertBool "rejected" (either (const True) (const False) (callbackPath uri))) ["/relative", "http://localhost", "http://localhost/cb?x=y", "http://localhost/cb#f", "http://user@localhost/cb"],
      testCase "file replacement is private complete and loads on restart" $ withDirectory $ \directory -> do
        (tokens, _) <- fixture
        let path = directory </> "tokens.json"
            store = fileTokenStore path
            replacement = tokens {R.accessToken = "new-access", R.refreshToken = Just "new-refresh"}
        tokenFileExists path >>= (@?= False)
        R.saveTokens store tokens
        mode <- F.fileMode <$> F.getFileStatus path
        mode .&. 0o077 @?= 0
        R.saveTokens store replacement
        R.loadTokens (fileTokenStore path) >>= (@?= replacement)
        L.readFile path >>= \bytes -> A.decode bytes @?= Just replacement
        D.listDirectory directory >>= (@?= ["tokens.json"]),
      testCase "pre-rename failure preserves old destination and cleans owned temporary" $ withDirectory $ \directory -> do
        (tokens, _) <- fixture
        let path = directory </> "tokens.json"
            store = fileTokenStore path
        R.saveTokens store tokens
        failed <- try @SomeException (R.saveTokens store tokens {R.workspaceName = Just (error "injected encoding failure")})
        assertBool "failed" (either (const True) (const False) failed)
        R.loadTokens store >>= (@?= tokens)
        D.listDirectory directory >>= (@?= ["tokens.json"]),
      testCase "reject symlink token paths and unsafe destination directories" $ withDirectory $ \directory -> do
        (tokens, _) <- fixture
        let target = directory </> "target.json"
            link = directory </> "tokens.json"
        R.saveTokens (fileTokenStore target) tokens
        F.createSymbolicLink target link
        failure <- try @IOException (R.saveTokens (fileTokenStore link) tokens)
        assertBool "symlink rejected" (either (const True) (const False) failure)
        R.loadTokens (fileTokenStore target) >>= (@?= tokens)
        F.setFileMode directory 0o777
        unsafe <- try @IOException (R.saveTokens (fileTokenStore target) tokens)
        F.setFileMode directory 0o700
        assertBool "unsafe directory rejected" (either (const True) (const False) unsafe),
      testCase "command lock excludes a second command and confirmed logout clears file" $ withDirectory $ \directory -> do
        (tokens, _) <- fixture
        let path = directory </> "tokens.json"
        withTokenFileLock path $ do
          R.saveTokens (fileTokenStore path) tokens
          second <- try @IOException (withTokenFileLock path (assertFailure "must not acquire"))
          assertBool "second command refused" (either (const True) (const False) second)
          R.loadTokens (fileTokenStore path) >>= (@?= tokens)
        withTokenFileLock path (removeTokenFile path)
        tokenFileExists path >>= (@?= False)
    ]