test: move jwt cache tests from io tests to spec tests

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
This commit is contained in:
Taimoor Zaeem
2026-06-25 11:21:28 -05:00
committed by Steve Chavez
parent 0bc9fe813f
commit b5f10be167
5 changed files with 86 additions and 88 deletions
+1
View File
@@ -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
+1 -88
View File
@@ -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"
+61
View File
@@ -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
+2
View File
@@ -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)
+21
View File
@@ -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 ""