diff --git a/postgrest.cabal b/postgrest.cabal index a5150ac91..507697316 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -223,6 +223,7 @@ test-suite spec Feature.Auth.AudienceJwtSecretSpec Feature.Auth.AuthSpec Feature.Auth.BinaryJwtSecretSpec + Feature.Auth.JwtCacheSpec Feature.Auth.NoAnonSpec Feature.Auth.NoJwtSecretSpec Feature.ConcurrentSpec diff --git a/test/io/test_auth.py b/test/io/test_auth.py index 188d69409..cec575b10 100644 --- a/test/io/test_auth.py +++ b/test/io/test_auth.py @@ -1,12 +1,11 @@ "Auth related IO tests for PostgREST" -from datetime import datetime, timedelta, timezone from operator import attrgetter import signal import pytest from config import BASEDIR, CONFIGSDIR, FIXTURES, SECRET -from util import authheader, jwtauthheader, parse_server_timings_header +from util import authheader, jwtauthheader from postgrest import ( run, sleep_until_postgrest_config_reload, @@ -187,92 +186,6 @@ def test_jwt_secret_external_file_reload(tmp_path, defaultenv): assert response.status_code == 401 -# TODO: This test is more related to observability than authentication. -# So, move it an appropriate test module. -def test_jwt_cache_server_timing(defaultenv): - "server-timing duration is exposed for JWT with expiry" - - env = { - **defaultenv, - "PGRST_SERVER_TIMING_ENABLED": "true", - "PGRST_JWT_CACHE_MAX_ENTRIES": "86400", - "PGRST_JWT_SECRET": SECRET, - "PGRST_DB_CONFIG": "false", - } - - headers = jwtauthheader( - { - "role": "postgrest_test_author", - "exp": int( - (datetime.now(timezone.utc) + timedelta(minutes=30)).timestamp() - ), - }, - SECRET, - ) - - with run(env=env) as postgrest: - first = postgrest.session.get("/authors_only", headers=headers) - second = postgrest.session.get("/authors_only", headers=headers) - - assert first.status_code == 200 - assert second.status_code == 200 - - first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"] - second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"] - - # with jwt caching the parse time of second request with the same token - # should be at least as fast as the first one - assert second_dur <= first_dur - - -def test_jwt_cache_without_server_timing(defaultenv): - "JWT cache does not break requests with server-timing disabled" - - env = { - **defaultenv, - "PGRST_SERVER_TIMING_ENABLED": "false", - "PGRST_JWT_CACHE_MAX_ENTRIES": "86400", - "PGRST_JWT_SECRET": SECRET, - "PGRST_DB_CONFIG": "false", - } - - headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) - - with run(env=env) as postgrest: - first = postgrest.session.get("/authors_only", headers=headers) - second = postgrest.session.get("/authors_only", headers=headers) - - assert first.status_code == 200 - assert second.status_code == 200 - - -def test_jwt_cache_without_exp_claim(defaultenv): - "server-timing duration is exposed for JWT without expiry" - - env = { - **defaultenv, - "PGRST_SERVER_TIMING_ENABLED": "true", - "PGRST_JWT_CACHE_MAX_ENTRIES": "86400", - "PGRST_JWT_SECRET": SECRET, - "PGRST_DB_CONFIG": "false", - } - - headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) # no exp - - with run(env=env) as postgrest: - first = postgrest.session.get("/authors_only", headers=headers) - second = postgrest.session.get("/authors_only", headers=headers) - - assert first.status_code == 200 - assert second.status_code == 200 - - first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"] - second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"] - - assert first_dur >= 0 - assert second_dur >= 0 - - def test_invalidate_jwt_cache_when_secret_changes(tmp_path, defaultenv): "JWT cache should be emptied after jwt-secret is changed in a config reload" diff --git a/test/spec/Feature/Auth/JwtCacheSpec.hs b/test/spec/Feature/Auth/JwtCacheSpec.hs new file mode 100644 index 000000000..34a5a14ad --- /dev/null +++ b/test/spec/Feature/Auth/JwtCacheSpec.hs @@ -0,0 +1,61 @@ +{-# LANGUAGE BangPatterns #-} +module Feature.Auth.JwtCacheSpec where + +import qualified Data.Map as M + +import Network.HTTP.Types +import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus)) +import Test.Hspec +import Test.Hspec.Wai +import Test.Hspec.Wai.JSON + +import PostgREST.Config (AppConfig (..)) + +import Protolude hiding (get) +import SpecHelper + +spec :: SpecWithConfig +spec withConfig = do + + withConfig baseCfg { configJwtCacheMaxEntries = 86400 } $ do + it "server-timing duration is exposed for JWT with expiry" $ do + !currentTime <- liftIO $ relativeSeconds 1800 -- 30 minutes, evaluate strictly + let jwtPayload = [json|{ "role": "postgrest_test_author", "exp": #{currentTime} }|] + auth = authHeaderJWT $ generateJWT jwtPayload + + res1 <- request methodGet "/authors_only" [auth] "" + let jwtDur1 = M.lookup "jwt" $ parseServerTimingHeader $ simpleHeaders res1 + res2 <- request methodGet "/authors_only" [auth] "" + let jwtDur2 = M.lookup "jwt" $ parseServerTimingHeader $ simpleHeaders res2 + + -- With jwt caching the parse time of second request with the same token + -- should be at least as fast as the first one + let dur2IsLessThanEq = fromMaybe False $ liftA2 (<=) jwtDur2 jwtDur1 + liftIO $ dur2IsLessThanEq `shouldBe` True + + it "server-timing duration is exposed for JWT without expiry" $ do + let jwtPayload = [json|{ "role": "postgrest_test_author" }|] + auth = authHeaderJWT $ generateJWT jwtPayload + + res1 <- request methodGet "/authors_only" [auth] "" + let jwtDur1 = M.lookup "jwt" $ parseServerTimingHeader $ simpleHeaders res1 + res2 <- request methodGet "/authors_only" [auth] "" + let jwtDur2 = M.lookup "jwt" $ parseServerTimingHeader $ simpleHeaders res2 + + liftIO $ do + simpleStatus res1 `shouldBe` status200 + simpleStatus res2 `shouldBe` status200 + + let dur1Positive = maybe False (>= 0) jwtDur1 + let dur2Positive = maybe False (>= 0) jwtDur2 + liftIO $ do + dur1Positive `shouldBe` True + dur2Positive `shouldBe` True + + withConfig baseCfg { configServerTimingEnabled = False, configJwtCacheMaxEntries = 86400 } $ + it "JWT cache does not break requests with server-timing disabled" $ do + let jwtPayload = [json|{ "role": "postgrest_test_author" }|] + auth = authHeaderJWT $ generateJWT jwtPayload + + request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200 + request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200 diff --git a/test/spec/Main.hs b/test/spec/Main.hs index 3680b7db6..7d1222d34 100644 --- a/test/spec/Main.hs +++ b/test/spec/Main.hs @@ -24,6 +24,7 @@ import qualified Feature.Auth.AsymmetricJwtSpec import qualified Feature.Auth.AudienceJwtSecretSpec import qualified Feature.Auth.AuthSpec import qualified Feature.Auth.BinaryJwtSecretSpec +import qualified Feature.Auth.JwtCacheSpec import qualified Feature.Auth.NoAnonSpec import qualified Feature.Auth.NoJwtSecretSpec import qualified Feature.ConcurrentSpec @@ -115,6 +116,7 @@ main = do , ("Feature.Auth.AudienceJwtSecretSpec" , Feature.Auth.AudienceJwtSecretSpec.spec) , ("Feature.Auth.AuthSpec" , Feature.Auth.AuthSpec.spec) , ("Feature.Auth.BinaryJwtSecretSpec" , Feature.Auth.BinaryJwtSecretSpec.spec) + , ("Feature.Auth.JwtCacheSpec" , Feature.Auth.JwtCacheSpec.spec) , ("Feature.Auth.NoAnonSpec" , Feature.Auth.NoAnonSpec.spec) , ("Feature.Auth.NoJwtSecretSpec" , Feature.Auth.NoJwtSecretSpec.spec) , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec) diff --git a/test/spec/SpecHelper.hs b/test/spec/SpecHelper.hs index 0a67164b0..025f5391d 100644 --- a/test/spec/SpecHelper.hs +++ b/test/spec/SpecHelper.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE TupleSections #-} module SpecHelper where import Control.Lens ((^?)) @@ -83,6 +84,26 @@ matchServerTimingHasTiming metric = MatchHeader $ \headers _body -> 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 ""