module SpecHelper where import qualified Data.Aeson as JSON import qualified Data.ByteString.Base64 as B64 (decodeLenient) import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Lazy as BL import qualified Data.Map.Strict as M import qualified Data.Set as S import qualified Jose.Jwa as JWT import qualified Jose.Jws as JWT import qualified Jose.Jwt as JWT import Control.Lens ((^?)) import Data.Aeson ((.=)) import Data.CaseInsensitive (CI (..), original) import Data.List (lookup) import Data.List.NonEmpty (fromList) import Data.Scientific (toRealFloat) import Data.String (String) import Data.Time.Clock.POSIX (getPOSIXTime) import Network.Wai (Application) import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus)) import System.IO.Unsafe (unsafePerformIO) import Text.Regex.TDFA ((=~)) import PostgREST.Config (AppConfig (..), LogLevel (..), OpenAPIMode (..), Verbosity (..), defaultRoleJSPathKey, parseSecret) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import Data.Aeson.Lens import Network.HTTP.Types import Test.Hspec import Test.Hspec.Wai import Protolude hiding (get, toS) import Protolude.Conv (toS) filterAndMatchCT :: BS.ByteString -> MatchHeader filterAndMatchCT val = MatchHeader $ \headers _ -> case filter (\(n,_) -> n == hContentType) headers of [(_,v)] -> if v == val then Nothing else Just $ "missing value:" <> toS val <> "\n" _ -> Just "unexpected header: zero or multiple headers present\n" matchContentTypeJson :: MatchHeader matchContentTypeJson = filterAndMatchCT "application/json; charset=utf-8" matchContentTypeSingular :: MatchHeader matchContentTypeSingular = filterAndMatchCT "application/vnd.pgrst.object+json; charset=utf-8" matchCTArrayStrip :: MatchHeader matchCTArrayStrip = filterAndMatchCT "application/vnd.pgrst.array+json;nulls=stripped; charset=utf-8" matchCTSingularStrip :: MatchHeader matchCTSingularStrip = filterAndMatchCT "application/vnd.pgrst.object+json;nulls=stripped; charset=utf-8" matchHeaderValuePresent :: HeaderName -> BS.ByteString -> MatchHeader matchHeaderValuePresent name val = MatchHeader $ \headers _ -> case lookup name headers of Just hdr -> if val `BS.isInfixOf` hdr then Nothing else Just $ "missing header value: " <> toS val <> "\n" Nothing -> Just $ "missing header: " <> toS (original name) <> "\n" matchHeaderAbsent :: HeaderName -> MatchHeader matchHeaderAbsent name = MatchHeader $ \headers _body -> case lookup name headers of Just _ -> Just $ "unexpected header: " <> toS (original name) <> "\n" Nothing -> Nothing -- | Matches Server-Timing header has a well-formed metric with the given name matchServerTimingHasTiming :: String -> MatchHeader matchServerTimingHasTiming metric = MatchHeader $ \headers _body -> case lookup "Server-Timing" headers of Just hdr -> if hdr =~ (metric <> ";dur=[[:digit:]]+.[[:digit:]]+") then Nothing else Just $ "missing metric: " <> metric <> "\n" Nothing -> Just "missing Server-Timing header\n" parseServerTimingHeader :: [Header] -> M.Map BS.ByteString Double parseServerTimingHeader [] = M.empty parseServerTimingHeader (h:hs) = case h of ("Server-Timing", timingHeader) -> let timings = BS.split ',' timingHeader in M.fromList $ mapMaybe splitEachTiming timings _ -> parseServerTimingHeader hs where splitEachTiming :: ByteString -> Maybe (BS.ByteString, Double) splitEachTiming t = case BS.split ';' t of [name, durationText] -> case BS.split '=' durationText of [_, duration] -> (name,) <$> readMaybe (BS.unpack duration) _ -> Nothing _ -> Nothing validateOpenApiResponse :: [Header] -> WaiSession () () validateOpenApiResponse headers = do r <- request methodGet "/" headers "" liftIO $ let respStatus = simpleStatus r in respStatus `shouldSatisfy` \s -> s == Status { statusCode = 200, statusMessage="OK" } liftIO $ let respHeaders = simpleHeaders r in respHeaders `shouldSatisfy` \hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs Just body <- pure $ JSON.decode (simpleBody r) Just schema <- liftIO $ JSON.decode <$> BL.readFile "test/spec/fixtures/openapi.json" let args :: M.Map Text JSON.Value args = M.fromList [ ( "schema", schema ) , ( "data", body ) ] hdrs = acceptHdrs "application/json" request methodPost "/rpc/validate_json_schema" hdrs (JSON.encode args) `shouldRespondWith` "true" { matchStatus = 200 , matchHeaders = [] } type SpecWithConfig = (AppConfig -> SpecWith ((), Application) -> Spec) -> Spec baseCfg :: AppConfig baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in AppConfig { configAppSettings = [ ("app.settings.app_host", "localhost") , ("app.settings.external_api_secret", "0123456789abcdef") ] , configClientErrorVerbosity = Verbose , configDbAggregates = False , configDbAnonRole = Just "postgrest_test_anonymous" , configDbChannel = mempty , configDbChannelEnabled = True , configDbExtraSearchPath = [] , configDbHoistedTxSettings = ["default_transaction_isolation","plan_filter.statement_cost_limit","statement_timeout"] , configDbMaxRows = Nothing , configDbPlanEnabled = False , configDbPoolSize = 10 , configDbPoolAcquisitionTimeout = 10 , configDbPoolMaxLifetime = 1800 , configDbPoolMaxIdletime = 600 , configDbPoolAutomaticRecovery = True , configDbPreRequest = Just $ QualifiedIdentifier "test" "switch_role" , configDbPreparedStatements = True , configDbRootSpec = Nothing , configDbSchemas = fromList ["test"] , configDbConfig = False , configDbPreConfig = Nothing , configDbUri = "postgresql://" , configFilePath = Nothing , configJWKS = rightToMaybe $ parseSecret secret , configJwtAudience = Nothing , configJwtRoleClaimKey = defaultRoleJSPathKey -- $.role , configJwtSecret = Just secret , configJwtSecretIsBase64 = False , configJwtCacheMaxEntries = 10 , configLogLevel = LogCrit , configLogQuery = False , configOpenApiMode = OAFollowPriv , configOpenApiSecurityActive = False , configOpenApiServerProxyUri = Nothing , configServerCorsAllowedOrigins = [] , configServerHost = "localhost" , configServerPort = 3000 , configServerReusePort = False , configServerTraceHeader = Nothing , configServerUnixSocket = Nothing , configServerUnixSocketMode = 432 , configUrlUseLegacyTargetNames = True , configDbTxAllowOverride = True , configDbTxRollbackAll = True , configAdminServerHost = "localhost" , configAdminServerPort = Nothing , configAdminServerUnixSocket = Nothing , configAdminServerUnixSocketMode = 432 , configRoleSettings = mempty , configRoleIsoLvl = mempty , configInternalSCQuerySleepFst = Nothing , configInternalSCQuerySleepSnd = Nothing , configServerTimingEnabled = True } rangeHdrs :: ByteRange -> [Header] rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)] rangeHdrsWithCount :: ByteRange -> [Header] rangeHdrsWithCount r = ("Prefer", "count=exact") : rangeHdrs r acceptHdrs :: BS.ByteString -> [Header] acceptHdrs mime = [(hAccept, mime)] planHdr :: Header planHdr = (hAccept, "application/vnd.pgrst.plan+json") rangeUnit :: Header rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items") matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool matchHeader name valRegex headers = maybe False (=~ valRegex) $ lookup name headers noBlankHeader :: [Header] -> Bool noBlankHeader = notElem mempty noProfileHeader :: [Header] -> Bool noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers notZeroContentLength :: [Header] -> Bool notZeroContentLength headers = maybe False (/= "0") $ lookup hContentLength headers authHeader :: BS.ByteString -> BS.ByteString -> Header authHeader typ creds = (hAuthorization, typ <> " " <> creds) authHeaderJWT :: BS.ByteString -> Header authHeaderJWT = authHeader "Bearer" generateSecret :: ByteString generateSecret = B64.decodeLenient "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" generateJWT :: BL.ByteString -> ByteString generateJWT claims = either mempty JWT.unJwt $ JWT.hmacEncode JWT.HS256 generateSecret (BL.toStrict claims) generateJWTWithSecret :: BL.ByteString -> ByteString -> ByteString generateJWTWithSecret claims secret = either mempty JWT.unJwt $ JWT.hmacEncode JWT.HS256 secret (BL.toStrict claims) -- | Tests whether the text can be parsed as a json object containing -- the key "message", and optional keys "details", "hint", "code", -- and no extraneous keys isErrorFormat :: BL.ByteString -> Bool isErrorFormat s = "message" `S.member` keys && S.null (S.difference keys validKeys) where obj = JSON.decode s :: Maybe (M.Map Text JSON.Value) keys = maybe S.empty M.keysSet obj validKeys = S.fromList ["message", "details", "hint", "code"] planCost :: SResponse -> Float planCost resp = let res = simpleBody resp ^? nth 0 . key "Plan" . key "Total Cost" in -- big value in case parsing fails fromMaybe 1_000_000_000.0 $ unbox =<< res where unbox :: JSON.Value -> Maybe Float unbox (JSON.Number n) = Just $ toRealFloat n unbox _ = Nothing data TiobePlsRow = TiobePlsRow { name' :: Text, rank :: Int } deriving (Show) instance JSON.ToJSON TiobePlsRow where toJSON (TiobePlsRow name'' rank') = JSON.object ["name" .= name'', "rank" .= rank'] getInsertDataForTiobePlsTable :: Int -> BL.ByteString getInsertDataForTiobePlsTable rows = JSON.encode $ fromList $ [TiobePlsRow {name' = nm, rank = rk} | (nm,rk) <- nameRankList] where nameRankList = [("Lang " <> show i, i) | i <- [20..(rows+20)] ] :: [(Text, Int)] readFixtureFile :: FilePath -> BL.ByteString readFixtureFile file = unsafePerformIO $ BL.readFile $ "test/spec/fixtures/" <> file relativeSeconds :: Integer -> IO Integer relativeSeconds s = do currTime <- getPOSIXTime return $ floor currTime + s