nix(test): add test suite for observability tests
- Create separate test suite for observability tests - Create wrapper script `postgrest-test-observability` - Add to CI and `postgrest-check` - Move JWT cache tests under observability tests Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
This commit is contained in:
committed by
Steve Chavez
parent
796339172c
commit
12ef63370b
@@ -0,0 +1,54 @@
|
||||
module Main where
|
||||
|
||||
import qualified Hasql.Pool as P
|
||||
import qualified Hasql.Pool.Config as P
|
||||
import qualified Hasql.Transaction.Sessions as HT
|
||||
|
||||
import Data.Function (id)
|
||||
|
||||
import PostgREST.App (postgrest)
|
||||
import qualified PostgREST.AppState as AppState
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.Config.Database (queryPgVersion)
|
||||
import qualified PostgREST.Logger as Logger
|
||||
import qualified PostgREST.Metrics as Metrics
|
||||
import PostgREST.SchemaCache (querySchemaCache)
|
||||
|
||||
import qualified Observation.JwtCache
|
||||
|
||||
import ObsHelper
|
||||
import Protolude hiding (toList, toS)
|
||||
import Test.Hspec
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
pool <- P.acquire $ P.settings
|
||||
[ P.size 3
|
||||
, P.acquisitionTimeout 10
|
||||
, P.agingTimeout 60
|
||||
, P.idlenessTimeout 60
|
||||
, P.staticConnectionSettings (toUtf8 $ configDbUri testCfg)
|
||||
]
|
||||
|
||||
actualPgVersion <- either (panic . show) id <$> P.use pool (queryPgVersion False)
|
||||
|
||||
-- cached schema cache so most tests run fast
|
||||
baseSchemaCache <- loadSCache pool testCfg
|
||||
loggerState <- Logger.init
|
||||
metricsState <- Metrics.init (configDbPoolSize testCfg)
|
||||
|
||||
let
|
||||
initApp sCache st config = do
|
||||
appState <- AppState.initWithPool pool config loggerState metricsState (Metrics.observationMetrics metricsState)
|
||||
AppState.putPgVersion appState actualPgVersion
|
||||
AppState.putSchemaCache appState (Just sCache)
|
||||
return (st, postgrest (configLogLevel config) appState (pure ()))
|
||||
|
||||
-- Run all test modules
|
||||
hspec $ do
|
||||
before (initApp baseSchemaCache metricsState testCfgJwtCache) $
|
||||
describe "Observation.JwtCacheObs" Observation.JwtCache.spec
|
||||
|
||||
where
|
||||
loadSCache pool conf =
|
||||
either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ querySchemaCache conf)
|
||||
@@ -0,0 +1,135 @@
|
||||
{-# LANGUAGE AllowAmbiguousTypes #-}
|
||||
{-# LANGUAGE ExistentialQuantification #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
module ObsHelper where
|
||||
|
||||
import qualified Data.ByteString.Base64 as B64 (decodeLenient)
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import qualified Jose.Jwa as JWT
|
||||
import qualified Jose.Jws as JWT
|
||||
import qualified Jose.Jwt as JWT
|
||||
|
||||
import PostgREST.Config (AppConfig (..), JSPathExp (..),
|
||||
LogLevel (..), OpenAPIMode (..),
|
||||
Verbosity (..), parseSecret)
|
||||
|
||||
import Data.List.NonEmpty (fromList)
|
||||
import Data.String (String)
|
||||
import Prometheus (Counter, getCounter)
|
||||
import Test.Hspec.Expectations.Contrib (annotate)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Protolude
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
|
||||
baseCfg :: AppConfig
|
||||
baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in
|
||||
AppConfig {
|
||||
configAppSettings = []
|
||||
, 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 = Nothing
|
||||
, configDbPreparedStatements = True
|
||||
, configDbRootSpec = Nothing
|
||||
, configDbSchemas = fromList ["test"]
|
||||
, configDbConfig = False
|
||||
, configDbPreConfig = Nothing
|
||||
, configDbUri = "postgresql://"
|
||||
, configFilePath = Nothing
|
||||
, configJWKS = rightToMaybe $ parseSecret secret
|
||||
, configJwtAudience = Nothing
|
||||
, configJwtRoleClaimKey = [JSPKey "role"]
|
||||
, configJwtSecret = Just secret
|
||||
, configJwtSecretIsBase64 = False
|
||||
, configJwtCacheMaxEntries = 10
|
||||
, configLogLevel = LogCrit
|
||||
, configLogQuery = False
|
||||
, configOpenApiMode = OAFollowPriv
|
||||
, configOpenApiSecurityActive = False
|
||||
, configOpenApiServerProxyUri = Nothing
|
||||
, configServerCorsAllowedOrigins = Nothing
|
||||
, configServerHost = "localhost"
|
||||
, configServerPort = 3000
|
||||
, configServerTraceHeader = Nothing
|
||||
, configServerUnixSocket = Nothing
|
||||
, configServerUnixSocketMode = 432
|
||||
, configDbTxAllowOverride = True
|
||||
, configDbTxRollbackAll = True
|
||||
, configAdminServerHost = "localhost"
|
||||
, configAdminServerPort = Nothing
|
||||
, configRoleSettings = mempty
|
||||
, configRoleIsoLvl = mempty
|
||||
, configInternalSCQuerySleep = Nothing
|
||||
, configInternalSCLoadSleep = Nothing
|
||||
, configInternalSCRelLoadSleep = Nothing
|
||||
, configServerTimingEnabled = True
|
||||
}
|
||||
|
||||
testCfg :: AppConfig
|
||||
testCfg = baseCfg
|
||||
|
||||
testCfgJwtCache :: AppConfig
|
||||
testCfgJwtCache =
|
||||
baseCfg {
|
||||
configJwtSecret = Just generateSecret
|
||||
, configJWKS = rightToMaybe $ parseSecret generateSecret
|
||||
, configJwtCacheMaxEntries = 2
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
-- state check helpers
|
||||
|
||||
data StateCheck st m = forall a. StateCheck (st -> (String, m a)) (a -> a -> Expectation)
|
||||
|
||||
stateCheck :: (Show a, Eq a) => (c -> m a) -> (st -> (String, c)) -> (a -> a) -> StateCheck st m
|
||||
stateCheck extractValue extractComponent expect = StateCheck (second extractValue . extractComponent) (flip shouldBe . expect)
|
||||
|
||||
expectField :: forall s st a c m. (KnownSymbol s, Show a, Eq a, HasField s st c) => (c -> m a) -> (a -> a) -> StateCheck st m
|
||||
expectField extractValue = stateCheck extractValue ((symbolVal (Proxy @s),) . getField @s)
|
||||
|
||||
checkState :: (Traversable t) => t (StateCheck st (WaiSession st)) -> WaiSession st b -> WaiSession st ()
|
||||
checkState checks act = getState >>= flip (`checkState'` checks) act
|
||||
|
||||
checkState' :: (Traversable t, MonadIO m) => st -> t (StateCheck st m) -> m b -> m ()
|
||||
checkState' initialState checks act = do
|
||||
expectations <- traverse (\(StateCheck g expect) -> let (msg, m) = g initialState in m >>= createExpectation msg m . expect) checks
|
||||
void act
|
||||
sequenceA_ expectations
|
||||
where
|
||||
createExpectation msg metrics expect = pure $ metrics >>= liftIO . annotate msg . expect
|
||||
|
||||
expectCounter :: forall s st m. (KnownSymbol s, HasField s st Counter, MonadIO m) => (Int -> Int) -> StateCheck st m
|
||||
expectCounter = expectField @s intCounter
|
||||
where
|
||||
intCounter = ((round @Double @Int) <$>) . getCounter
|
||||
@@ -0,0 +1,138 @@
|
||||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
module Observation.JwtCache where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec (SpecWith, describe, it)
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import ObsHelper
|
||||
import PostgREST.Metrics (MetricsState (..))
|
||||
import Protolude
|
||||
import Test.Hspec.Wai.JSON (json)
|
||||
|
||||
spec :: SpecWith (MetricsState, Application)
|
||||
spec = describe "Server started with JWT and metrics enabled" $ do
|
||||
it "Should not have JWT in cache" $ do
|
||||
let auth = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe1"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 1)
|
||||
, hits (+ 0)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200
|
||||
|
||||
it "Should have JWT in cache" $ do
|
||||
let auth = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe2"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 2)
|
||||
, hits (+ 1)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200
|
||||
*> request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200
|
||||
|
||||
it "Should not cache invalid JWTs" $ do
|
||||
let auth = authHeaderJWT "some random bytes"
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 2)
|
||||
, hits (+ 0)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
|
||||
*> request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
|
||||
|
||||
it "Should cache expired JWTs" $ do
|
||||
let auth = genToken [json|{"exp": 1, "role": "postgrest_test_author", "id": "jdoe2"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 2)
|
||||
, hits (+ 1)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
|
||||
*> request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
|
||||
|
||||
it "Should evict entries from the JWT cache (jwt cache max is 2)" $ do
|
||||
let jwt1 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe3"}|]
|
||||
jwt2 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe4"}|]
|
||||
jwt3 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe5"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 6)
|
||||
, hits (+ 0)
|
||||
, evictions (+ 4)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
*> request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
|
||||
it "Should not evict entries from the JWT cache in FIFO order" $ do
|
||||
let jwt1 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe6"}|]
|
||||
jwt2 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe7"}|]
|
||||
jwt3 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe8"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 6)
|
||||
, hits (+ 3)
|
||||
, evictions (+ 1)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
-- this one should hit the cache
|
||||
*> request methodGet "/authors_only" [jwt1] ""
|
||||
-- this one should trigger eviction of jwt2 (not FIFO)
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
-- these two should hit the cache
|
||||
*> request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
|
||||
-- This one makes sure we test the scenario when finger
|
||||
-- has to move through the whole list first and pass the head
|
||||
-- The test case was added based on coverage report
|
||||
-- showing this scenario was not covered by previous tests
|
||||
it "Should evict entries even though all were hit" $ do
|
||||
let jwt1 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe9"}|]
|
||||
jwt2 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe10"}|]
|
||||
jwt3 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe11"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 7)
|
||||
, hits (+ 4)
|
||||
, evictions (+ 1)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
-- these two should hit the cache
|
||||
*> request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
-- this one should trigger eviction of jwt1
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
-- these two should hit the cache
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
|
||||
where
|
||||
genToken = authHeaderJWT . generateJWT
|
||||
requests = expectCounter @"jwtCacheRequests"
|
||||
hits = expectCounter @"jwtCacheHits"
|
||||
evictions = expectCounter @"jwtCacheEvictions"
|
||||
expectCounters = checkState
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Suppress NOTICE: ... messages
|
||||
SET client_min_messages TO warning;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Loads all fixtures for the PostgREST observability tests
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
\ir database.sql
|
||||
\ir roles.sql
|
||||
\ir schema.sql
|
||||
\ir privileges.sql
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Schema test objects
|
||||
SET search_path = test, pg_catalog;
|
||||
|
||||
GRANT USAGE ON SCHEMA test TO postgrest_test_anonymous;
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA test TO postgrest_test_anonymous;
|
||||
REVOKE ALL PRIVILEGES ON TABLE authors_only FROM postgrest_test_anonymous;
|
||||
|
||||
GRANT USAGE ON SCHEMA test TO postgrest_test_author;
|
||||
GRANT ALL ON TABLE authors_only TO postgrest_test_author;
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP ROLE IF EXISTS postgrest_test_anonymous, postgrest_test_author;
|
||||
CREATE ROLE postgrest_test_anonymous;
|
||||
CREATE ROLE postgrest_test_author;
|
||||
|
||||
GRANT postgrest_test_anonymous, postgrest_test_author TO :PGUSER;
|
||||
@@ -0,0 +1,21 @@
|
||||
DROP SCHEMA IF EXISTS test;
|
||||
|
||||
CREATE SCHEMA test;
|
||||
|
||||
SET search_path = test, pg_catalog;
|
||||
|
||||
--
|
||||
-- Name: authors_only; Type: TABLE; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE authors_only (
|
||||
owner character varying NOT NULL,
|
||||
secret character varying NOT NULL
|
||||
);
|
||||
|
||||
--
|
||||
-- Name: authors_only_pkey; Type: CONSTRAINT; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY authors_only
|
||||
ADD CONSTRAINT authors_only_pkey PRIMARY KEY (secret);
|
||||
Reference in New Issue
Block a user