Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,18 @@ jobs:
uses: ./.github/actions/setup-nix
with:
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
tools: tests.testSpec.bin tests.testIO.bin tests.testBigSchema.bin withTools.pg-${{ matrix.pgVersion }}.bin cabalTools.update.bin
tools: tests.testSpec.bin tests.testObservability.bin tests.testIO.bin tests.testBigSchema.bin withTools.pg-${{ matrix.pgVersion }}.bin cabalTools.update.bin

- run: postgrest-cabal-update

- name: Run spec tests
if: always()
run: postgrest-with-pg-${{ matrix.pgVersion }} postgrest-test-spec

- name: Run observability tests
if: always()
run: postgrest-with-pg-${{ matrix.pgVersion }} postgrest-test-observability

- name: Run IO tests
if: always()
run: postgrest-with-pg-${{ matrix.pgVersion }} postgrest-test-io -vv
Expand Down
1 change: 1 addition & 0 deletions nix/tools/devTools.nix
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ let
}
''
${tests}/bin/postgrest-test-spec
${tests}/bin/postgrest-test-observability
${tests}/bin/postgrest-test-doctests
${tests}/bin/postgrest-test-io
${tests}/bin/postgrest-test-big-schema
Expand Down
24 changes: 22 additions & 2 deletions nix/tools/tests.nix
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ let
${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:spec -- "''${_arg_leftovers[@]}"
'';

testObservability =
checkedShellScript
{
name = "postgrest-test-observability";
docs = "Run the Haskell observability test suite.";
args = [ "ARG_LEFTOVERS([hspec arguments])" ];
workingDir = "/";
withEnv = postgrest.env;
}
''
${withTools.withPg} -f test/observability/fixtures/load.sql \
${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:observability -- "''${_arg_leftovers[@]}"
'';

testDoctests =
checkedShellScript
{
Expand Down Expand Up @@ -155,7 +169,7 @@ let
rm -rf coverage/*

# build once before running all the tests
${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest lib:postgrest test:spec
${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest lib:postgrest test:spec test:observability

(
trap 'echo Found dead code: Check file list above.' ERR ;
Expand All @@ -179,11 +193,16 @@ let
${withTools.withPg} -f test/spec/fixtures/load.sql \
${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:spec

HPCTIXFILE="$tmpdir"/observability.tix \
${withTools.withPg} -f test/observability/fixtures/load.sql \
${cabal-install}/bin/cabal v2-run ${devCabalOptions} test:observability

# Note: No coverage for doctests, as doctests leverage GHCi and GHCi does not support hpc

# collect all the tix files
${ghc}/bin/hpc sum --union --exclude=Paths_postgrest --output="$tmpdir"/tests.tix \
"$tmpdir"/io*.tix "$tmpdir"/big_schema*.tix "$tmpdir"/replica*.tix "$tmpdir"/spec.tix
"$tmpdir"/io*.tix "$tmpdir"/big_schema*.tix "$tmpdir"/replica*.tix "$tmpdir"/spec.tix \
"$tmpdir"/observability.tix

# prepare the overlay
${ghc}/bin/hpc overlay --output="$tmpdir"/overlay.tix test/coverage.overlay
Expand Down Expand Up @@ -250,6 +269,7 @@ buildToolbox
tools = {
inherit
testSpec
testObservability
testDoctests
testSpecIdempotence
testIO
Expand Down
32 changes: 31 additions & 1 deletion postgrest.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,6 @@ test-suite spec
Feature.Auth.AudienceJwtSecretSpec
Feature.Auth.AuthSpec
Feature.Auth.BinaryJwtSecretSpec
Feature.Auth.JwtCacheSpec
Feature.Auth.NoAnonSpec
Feature.Auth.NoJwtSecretSpec
Feature.ConcurrentSpec
Expand Down Expand Up @@ -296,6 +295,37 @@ test-suite spec
-- https://github.com/PostgREST/postgrest/issues/387
-with-rtsopts=-K33K

test-suite observability
type: exitcode-stdio-1.0
default-language: Haskell2010
default-extensions: OverloadedStrings
QuasiQuotes
NoImplicitPrelude
hs-source-dirs: test/observability
main-is: Main.hs
other-modules: ObsHelper
Observation.JwtCache
build-depends: base >= 4.9 && < 4.20
, base64-bytestring >= 1 && < 1.3
, bytestring >= 0.10.8 && < 0.13
, hasql-pool >= 1.0.1 && < 1.1
, hasql-transaction >= 1.0.1 && < 1.2
, hspec >= 2.3 && < 2.12
, hspec-expectations >= 0.8.4 && < 0.9
, hspec-wai >= 0.10 && < 0.12
, hspec-wai-json >= 0.10 && < 0.12
, http-types >= 0.12.3 && < 0.13
, jose-jwt >= 0.9.6 && < 0.11
, postgrest
, prometheus-client >= 1.1.1 && < 1.2.0
, protolude >= 0.3.1 && < 0.4
, wai >= 3.2.1 && < 3.3
ghc-options: -threaded -O0 -Werror -Wall -fwarn-identities
-fno-spec-constr -optP-Wno-nonportable-include-path
-fwrite-ide-info
-- https://github.com/PostgREST/postgrest/issues/387
-with-rtsopts=-K33K

test-suite doctests
type: exitcode-stdio-1.0
default-language: Haskell2010
Expand Down
54 changes: 54 additions & 0 deletions test/observability/Main.hs
Original file line number Diff line number Diff line change
@@ -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)
135 changes: 135 additions & 0 deletions test/observability/ObsHelper.hs
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +31 to +109
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see there's duplication here but don't have a good idea on how to solve right now.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, I feel the same way. I believe the long term solution would be to come with some sort of internal helper library of functions, but that would be too much for now.


-- 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
Copy link
Copy Markdown
Member

@steve-chavez steve-chavez Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked at our current

spec :: SpecWith (MetricsState, Application)

It is indeed purely tested in terms of metrics, so I agree in that it fits here.

Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ImpredicativeTypes #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
module Feature.Auth.JwtCacheSpec

where
{-# 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 SpecHelper
import Test.Hspec.Wai.JSON (json)

spec :: SpecWith (MetricsState, Application)
Expand All @@ -32,7 +24,7 @@ spec = describe "Server started with JWT and metrics enabled" $ do
, hits (+ 0)
] $

request methodGet "/authors_only" [auth] ""
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"}|]
Expand Down
2 changes: 2 additions & 0 deletions test/observability/fixtures/database.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Suppress NOTICE: ... messages
SET client_min_messages TO warning;
8 changes: 8 additions & 0 deletions test/observability/fixtures/load.sql
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions test/observability/fixtures/privileges.sql
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions test/observability/fixtures/roles.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading