Skip to content
73 changes: 73 additions & 0 deletions .github/workflows/ci-test-postgrest-db.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: CI test postgrest-db

on:
pull_request:
paths:
- 'postgrest-tandem/**'
push:
branches:
- main
paths:
- 'postgrest-tandem/**'

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image tarball
uses: docker/build-push-action@v6
with:
context: ./postgrest-tandem/image
file: ./postgrest-tandem/image/Containerfile
push: false
tags: local/postgrest-db:ci
outputs: type=docker,dest=/tmp/postgrest-db-ci.tar
build-args: |
IMAGE_VERSION=ci
VCS_REF=${{ github.sha }}
SOURCE_URL=${{ github.server_url }}/${{ github.repository }}
POSTGRES_BASE_TAG=16

- name: Upload image artifact
uses: actions/upload-artifact@v4
with:
name: postgrest-db-image
path: /tmp/postgrest-db-ci.tar
if-no-files-found: error

integration-test:
runs-on: ubuntu-latest
needs: build

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Download image artifact
uses: actions/download-artifact@v4
with:
name: postgrest-db-image
path: /tmp

- name: Load image
run: docker load -i /tmp/postgrest-db-ci.tar

- name: Install test dependencies
run: |
sudo apt-get update
sudo apt-get install -y bats bats-support bats-assert postgresql-client jq

- name: Run BATS integration tests
env:
TEST_IMAGE: local/postgrest-db:ci
run: bats postgrest-tandem/tests/
23 changes: 23 additions & 0 deletions .github/workflows/publish-postgrest-db.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ jobs:
type=raw,value=${{ steps.vars.outputs.version }}
type=raw,value=latest

- name: Build image for vulnerability scan
uses: docker/build-push-action@v6
with:
context: ./postgrest-tandem/image
file: ./postgrest-tandem/image/Containerfile
push: false
load: true
tags: ${{ steps.vars.outputs.image }}:${{ steps.vars.outputs.version }}
build-args: |
IMAGE_VERSION=${{ steps.vars.outputs.version }}
VCS_REF=${{ github.sha }}
SOURCE_URL=${{ github.server_url }}/${{ github.repository }}
POSTGRES_BASE_TAG=16

- name: Scan image with Trivy
uses: aquasecurity/trivy-action@0.25.0
with:
image-ref: ${{ steps.vars.outputs.image }}:${{ steps.vars.outputs.version }}
format: table
exit-code: '1'
severity: CRITICAL,HIGH
ignore-unfixed: true

- name: Build and push image
uses: docker/build-push-action@v6
with:
Expand Down
34 changes: 34 additions & 0 deletions postgrest-tandem/tests/test_api.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bats

load ./test_helper.bash

setup_file() {
start_postgrest_db
ensure_alice_user
}

teardown_file() {
stop_postgrest_db
}

@test "api.login called as anon returns valid JWT" {
run psql_authenticator "SET ROLE anon; SELECT (api.login('alice', 'correct-password')).token;"
[ "$status" -eq 0 ]
token="$(printf '%s\n' "$output" | tail -n1)"
[ -n "$token" ]

part_count="$(printf '%s' "$token" | awk -F'.' '{print NF}')"
[ "$part_count" -eq 3 ]
}

@test "api.login called as anon with wrong password raises invalid_password" {
run psql_authenticator "SET ROLE anon; DO \
\\$\\$ BEGIN \
PERFORM api.login('alice', 'wrong-password'); \
RAISE EXCEPTION 'expected invalid password'; \
EXCEPTION WHEN invalid_password THEN \
RAISE NOTICE 'caught:%', SQLSTATE; \
END \\$\\$;"
[ "$status" -eq 0 ]
[[ "$output" == *"caught:28P01"* ]]
}
77 changes: 77 additions & 0 deletions postgrest-tandem/tests/test_auth.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bats

load ./test_helper.bash

setup_file() {
start_postgrest_db
ensure_alice_user
}

teardown_file() {
stop_postgrest_db
}

@test "storing a user hashes password with bcrypt" {
run psql_super "SELECT password FROM auth.users WHERE username = 'alice';"
[ "$status" -eq 0 ]
[[ "$output" == \$2a\$* || "$output" == \$2b\$* ]]
}

@test "auth.login returns a JWT with role and ~15 minute expiry" {
run psql_super "SELECT (auth.login('alice', 'correct-password')).token;"
[ "$status" -eq 0 ]
token="$output"
[ -n "$token" ]

part_count="$(printf '%s' "$token" | awk -F'.' '{print NF}')"
[ "$part_count" -eq 3 ]

payload_json="$(decode_jwt_payload "$token")"
role="$(printf '%s' "$payload_json" | jq -r '.role')"
exp="$(printf '%s' "$payload_json" | jq -r '.exp')"
now_epoch="$(date +%s)"

[ "$role" = "anon" ]
[ "$exp" -ge $((now_epoch + 14 * 60)) ]
[ "$exp" -le $((now_epoch + 16 * 60)) ]
}

@test "auth.login wrong password raises invalid_password SQLSTATE 28P01" {
run psql_super "DO \
\\$\\$ BEGIN \
PERFORM auth.login('alice', 'wrong-password'); \
RAISE EXCEPTION 'expected invalid password'; \
EXCEPTION WHEN invalid_password THEN \
RAISE NOTICE 'caught:%', SQLSTATE; \
END \\$\\$;"
[ "$status" -eq 0 ]
[[ "$output" == *"caught:28P01"* ]]
}

@test "inserting user with unknown role is rejected" {
run psql_super_raw "INSERT INTO auth.users (username, password, role) VALUES ('missing-role', 'correct-password', 'does_not_exist');"
[ "$status" -ne 0 ]
[[ "$output" == *"role \"does_not_exist\" does not exist"* ]]
}

@test "inserting user with short password is rejected" {
run psql_super_raw "INSERT INTO auth.users (username, password, role) VALUES ('short-pass', 'short', 'anon');"
[ "$status" -ne 0 ]
[[ "$output" == *"violates check constraint"* ]]
}

@test "updating password re-hashes to a different bcrypt hash" {
run psql_super "SELECT password FROM auth.users WHERE username = 'alice';"
[ "$status" -eq 0 ]
before_hash="$output"

run psql_super "UPDATE auth.users SET password = 'new-correct-password' WHERE username = 'alice';"
[ "$status" -eq 0 ]

run psql_super "SELECT password FROM auth.users WHERE username = 'alice';"
[ "$status" -eq 0 ]
after_hash="$output"

[[ "$after_hash" == \$2a\$* || "$after_hash" == \$2b\$* ]]
[ "$before_hash" != "$after_hash" ]
}
80 changes: 80 additions & 0 deletions postgrest-tandem/tests/test_helper.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash

POSTGREST_DB_CONTAINER="postgrest-db-bats"
POSTGREST_DB_PORT="55432"
POSTGRES_PASSWORD="postgres-ci-only"
AUTHENTICATOR_PASSWORD="testpw-ci-only"
Comment on lines +3 to +6
TEST_IMAGE="${TEST_IMAGE:-local/postgrest-db:ci}"

psql_super() {
PGPASSWORD="$POSTGRES_PASSWORD" psql -X -qAt -v ON_ERROR_STOP=1 \
-h 127.0.0.1 -p "$POSTGREST_DB_PORT" -U postgres -d postgres -c "$1"
}

psql_super_raw() {
PGPASSWORD="$POSTGRES_PASSWORD" psql -X -qAt \
-h 127.0.0.1 -p "$POSTGREST_DB_PORT" -U postgres -d postgres -c "$1"
}

psql_authenticator() {
PGPASSWORD="$AUTHENTICATOR_PASSWORD" psql -X -qAt -v ON_ERROR_STOP=1 \
-h 127.0.0.1 -p "$POSTGREST_DB_PORT" -U authenticator -d postgres -c "$1"
}

wait_for_postgres() {
local attempts=60
while (( attempts > 0 )); do
if psql_super 'SELECT 1;' >/dev/null 2>&1; then
return 0
fi
sleep 1
attempts=$((attempts - 1))
done
return 1
}

start_postgrest_db() {
docker rm -f "$POSTGREST_DB_CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$POSTGREST_DB_CONTAINER" \
-p "$POSTGREST_DB_PORT:5432" \
-e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
-e AUTHENTICATOR_PASSWORD="$AUTHENTICATOR_PASSWORD" \
"$TEST_IMAGE" >/dev/null

if ! wait_for_postgres; then
docker logs "$POSTGREST_DB_CONTAINER" >&2 || true
return 1
fi
}

stop_postgrest_db() {
docker rm -f "$POSTGREST_DB_CONTAINER" >/dev/null 2>&1 || true
}

ensure_alice_user() {
psql_super "
INSERT INTO auth.users (username, password, role)
VALUES ('alice', 'correct-password', 'anon')
ON CONFLICT (username) DO UPDATE
SET password = EXCLUDED.password,
role = EXCLUDED.role;
"
}

decode_jwt_payload() {
local token="$1"
local payload
local rem

payload="$(printf '%s' "$token" | cut -d'.' -f2 | tr '_-' '/+')"
rem=$(( ${#payload} % 4 ))
if [ "$rem" -eq 2 ]; then
payload+="=="
elif [ "$rem" -eq 3 ]; then
payload+="="
elif [ "$rem" -eq 1 ]; then
return 1
fi

printf '%s' "$payload" | base64 --decode
}
37 changes: 37 additions & 0 deletions postgrest-tandem/tests/test_init.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bats

load ./test_helper.bash

setup_file() {
start_postgrest_db
}

teardown_file() {
stop_postgrest_db
}

@test "authenticator and anon roles are bootstrapped correctly" {
run psql_super "SELECT rolname || '|' || rolcanlogin || '|' || rolinherit FROM pg_roles WHERE rolname = 'authenticator';"
[ "$status" -eq 0 ]
[ "$output" = "authenticator|t|f" ]

run psql_super "SELECT rolname || '|' || rolcanlogin FROM pg_roles WHERE rolname = 'anon';"
[ "$status" -eq 0 ]
[ "$output" = "anon|f" ]

run psql_super "SELECT pg_has_role('authenticator', 'anon', 'member');"
[ "$status" -eq 0 ]
[ "$output" = "t" ]
}

@test "required schemas exist" {
run psql_super "SELECT string_agg(nspname, ',') FROM (SELECT nspname FROM pg_namespace WHERE nspname IN ('postgrest','auth','api') ORDER BY nspname) s;"
[ "$status" -eq 0 ]
[ "$output" = "api,auth,postgrest" ]
}

@test "required extensions are installed" {
run psql_super "SELECT string_agg(extname, ',') FROM (SELECT extname FROM pg_extension WHERE extname IN ('plpython3u','pgcrypto') ORDER BY extname) e;"
[ "$status" -eq 0 ]
[ "$output" = "pgcrypto,plpython3u" ]
}
54 changes: 54 additions & 0 deletions postgrest-tandem/tests/test_jwt_secret.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bats

load ./test_helper.bash

setup_file() {
start_postgrest_db
}

teardown_file() {
stop_postgrest_db
}

@test "jwt_secret singleton has exactly one non-empty long secret" {
run psql_super "SELECT count(*) FROM postgrest.jwt_secret;"
[ "$status" -eq 0 ]
[ "$output" = "1" ]

run psql_super "SELECT length(secret) FROM postgrest.jwt_secret LIMIT 1;"
[ "$status" -eq 0 ]
[ "$output" -ge 64 ]
}

@test "pre_config exposes the persisted secret via pgrst.jwt_secret" {
run psql_super "WITH _ AS (SELECT postgrest.pre_config()) SELECT current_setting('pgrst.jwt_secret', true);"
[ "$status" -eq 0 ]
config_secret="$output"

run psql_super "SELECT secret FROM postgrest.jwt_secret LIMIT 1;"
[ "$status" -eq 0 ]
[ "$output" = "$config_secret" ]
}

@test "rotate_jwt_secret changes secret and advances updated_at" {
run psql_super "SELECT secret || '|' || extract(epoch FROM updated_at)::bigint FROM postgrest.jwt_secret LIMIT 1;"
[ "$status" -eq 0 ]
before="$output"

run psql_super "SELECT pg_sleep(1); SELECT postgrest.rotate_jwt_secret();"
[ "$status" -eq 0 ]

run psql_super "SELECT secret || '|' || extract(epoch FROM updated_at)::bigint FROM postgrest.jwt_secret LIMIT 1;"
[ "$status" -eq 0 ]
after="$output"

[ "$before" != "$after" ]

before_secret="${before%%|*}"
before_updated="${before##*|}"
after_secret="${after%%|*}"
after_updated="${after##*|}"

[ "$before_secret" != "$after_secret" ]
[ "$after_updated" -gt "$before_updated" ]
}
Loading