first commit

This commit is contained in:
Elzo codeur
2026-06-11 21:28:31 +00:00
commit cb7c512c57
58 changed files with 11212 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# Test override: exposes the S3 backend port for direct testing.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.rustfs.yml \
# -f ./tests/docker-compose.rustfs.test.yml up -d
#
services:
rustfs:
ports:
- "${S3_BACKEND_TEST_PORT:-9100}:9000"

View File

@@ -0,0 +1,13 @@
# Test override: exposes the S3 backend port for direct testing.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml \
# -f ./tests/docker-compose.s3.test.yml up -d
#
# When swapping to a different S3 backend (e.g. RustFS), update the
# service name and internal port to match the new backend.
services:
minio:
ports:
- "${S3_BACKEND_TEST_PORT:-9100}:9000"

369
tests/test-auth-keys.sh Normal file
View File

@@ -0,0 +1,369 @@
#!/bin/sh
#
# Test API key types and asymmetric auth against a running self-hosted instance.
#
# Usage:
# sh test-auth-keys.sh # Uses http://localhost:8000
# sh test-auth-keys.sh <base_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance
# - .env file with all keys configured
# - jq (for JSON parsing)
# - node >= 16 (for HS256 token minting test only)
#
set -e
BASE_URL="${1:-http://localhost:8000}"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
for cmd in jq node; do
if ! command -v $cmd >/dev/null 2>&1; then
echo "Error: $cmd not found."
exit 1
fi
done
# Read keys from .env
JWT_SECRET=$(grep '^JWT_SECRET=' .env | cut -d= -f2-)
ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-)
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-)
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-)
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name (HTTP $actual)"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
http_status() {
url="$1"
shift
curl -s -o /dev/null -w "%{http_code}" "$@" "$url"
}
echo ""
echo "=== Testing against $BASE_URL ==="
echo ""
# ---------------------------------------------
# 1. Route tests with API key types
# ---------------------------------------------
echo "--- REST API (/rest/v1/) ---"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $ANON_KEY")"
check "Legacy SERVICE_ROLE_KEY" "200" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SERVICE_ROLE_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
check "New SECRET_KEY" "200" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_SECRET_KEY")"
else
echo " SKIP: Opaque keys not configured"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/rest/v1/")"
check "Invalid key -> 401" "401" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: invalid-key")"
echo ""
echo "--- Auth (/auth/v1/settings) ---"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $ANON_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/auth/v1/settings")"
echo ""
echo "--- Storage (/storage/v1/bucket) ---"
# Storage has no key-auth - passes through, Storage returns its own errors
check "No key -> not 401 (Storage handles auth)" "true" \
"$([ "$(http_status "$BASE_URL/storage/v1/bucket")" != "401" ] && echo true || echo false)"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
# With opaque key, Kong translates to asymmetric JWT in Authorization
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
fi
echo ""
echo "--- Storage S3 (/storage/v1/s3/) ---"
# S3 uses AWS SigV4 auth (not apikey) - the request-transformer Lua expression
# passes the Authorization header through unchanged for non-sb_ values
check "S3 route accessible" "true" \
"$([ "$(http_status "$BASE_URL/storage/v1/s3/")" != "502" ] && echo true || echo false)"
echo ""
echo "--- GraphQL (/graphql/v1) ---"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/graphql/v1" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/graphql/v1" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/graphql/v1" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')"
echo ""
echo "--- Realtime REST (/realtime/v1/api/) ---"
# Realtime REST API - expect 200 or other non-401 response with valid key
check "Legacy ANON_KEY -> not 401" "true" \
"$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY -> not 401" "true" \
"$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" != "401" ] && echo true || echo false)"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/realtime/v1/api/tenants")"
echo ""
echo "--- supabase-js style requests (apikey + Authorization) ---"
# supabase-js sends both apikey header AND Authorization: Bearer <apikey>
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "apikey + Authorization: Bearer sb_ (replace path)" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")"
fi
check "Legacy apikey + Authorization: Bearer <legacy jwt>" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY")"
echo ""
echo "--- Edge cases ---"
# Opaque key in Authorization only (no apikey header) - should be rejected by key-auth
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "sb_ in Authorization only (no apikey) -> 401" "401" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")"
fi
echo ""
echo "--- JWKS endpoint ---"
check "JWKS public endpoint (no auth)" "200" \
"$(http_status "$BASE_URL/auth/v1/.well-known/jwks.json")"
# Verify JWKS content: should have EC key, should NOT have symmetric key
jwks_content=$(curl -s "$BASE_URL/auth/v1/.well-known/jwks.json")
jwks_has_ec=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "EC") then "true" else "false" end' 2>/dev/null)
jwks_has_oct=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "oct") then "true" else "false" end' 2>/dev/null)
check "JWKS contains EC public key" "true" "$jwks_has_ec"
check "JWKS does NOT contain symmetric key" "false" "$jwks_has_oct"
#echo ""
#echo "--- OAuth metadata endpoint ---"
#check "well-known oauth (no auth)" "200" \
# "$(http_status "$BASE_URL/.well-known/oauth-authorization-server")"
echo ""
echo "--- Realtime WebSocket upgrade ---"
# Test that WebSocket upgrade request gets through (expect 101 or non-401)
# curl --max-time to prevent hanging on successful upgrade (101 keeps connection open)
ws_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
"$BASE_URL/realtime/v1/websocket?apikey=$ANON_KEY&vsn=1.0.0" \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000")
# 101 = upgrade success, 000 = timeout (connection stayed open = success)
check "WebSocket upgrade with legacy key -> not 401" "true" \
"$([ "$ws_status" != "401" ] && echo true || echo false)"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
ws_status_new=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
"$BASE_URL/realtime/v1/websocket?apikey=$SUPABASE_PUBLISHABLE_KEY&vsn=1.0.0" \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000")
check "WebSocket upgrade with opaque key -> not 401" "true" \
"$([ "$ws_status_new" != "401" ] && echo true || echo false)"
fi
# ---------------------------------------------
# 2. User session JWT tests
# ---------------------------------------------
echo ""
echo "--- User session JWT ---"
# Create user via admin API (works regardless of email autoconfirm setting)
test_email="test-keys-$$@example.com"
test_password="test-password-123456"
create_resp=$(curl -s "$BASE_URL/auth/v1/admin/users" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}")
test_user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null)
# Sign in to get session JWT
auth_response=$(curl -s "$BASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}")
access_token=$(echo "$auth_response" | jq -r '.access_token // empty' 2>/dev/null)
if [ -n "$access_token" ]; then
# Check the algorithm in the JWT header
jwt_alg=$(echo "$access_token" | cut -d. -f1 | \
jq -Rr '@base64d | fromjson | .alg // empty' 2>/dev/null)
if [ -n "$jwt_alg" ]; then
echo " INFO: User session JWT signed with: $jwt_alg"
if [ "$jwt_alg" = "ES256" ]; then
check "JWT uses ES256 (asymmetric)" "ES256" "$jwt_alg"
else
check "JWT uses HS256 (legacy)" "HS256" "$jwt_alg"
fi
fi
# Use the session JWT with PostgREST
check "Session JWT with PostgREST" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $access_token")"
# Use the session JWT with Storage
check "Session JWT with Storage" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $access_token")"
# CRITICAL: Authenticated user + opaque key (most common supabase-js flow)
# supabase-js sends apikey: sb_publishable_xxx AND Authorization: Bearer <user_session_jwt>
# The expression MUST keep the user JWT and NOT replace it with the anon asymmetric JWT
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
echo ""
echo "--- Authenticated user + opaque key (critical path) ---"
check "Opaque apikey + user JWT -> PostgREST uses user JWT" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $access_token")"
check "Opaque apikey + user JWT -> Storage uses user JWT" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $access_token")"
check "Opaque apikey + user JWT -> Auth uses user JWT" "200" \
"$(http_status "$BASE_URL/auth/v1/user" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $access_token")"
fi
else
check "Sign in test user" "true" "false"
fi
# Clean up test user
if [ -n "$test_user_id" ]; then
curl -s -o /dev/null "$BASE_URL/auth/v1/admin/users/$test_user_id" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY"
fi
# ---------------------------------------------
# 3. HS256 backward compatibility
# ---------------------------------------------
echo ""
echo "--- HS256 backward compatibility ---"
# Mint a legacy HS256 JWT with role=anon (simulating a pre-migration token)
hs256_token=$(JWT_SECRET="$JWT_SECRET" node -e "
const crypto = require('crypto');
const header = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url');
const payload = Buffer.from(JSON.stringify({
role:'anon',iss:'supabase',
iat:Math.floor(Date.now()/1000),
exp:Math.floor(Date.now()/1000)+3600
})).toString('base64url');
const sig = crypto.createHmac('sha256',process.env.JWT_SECRET)
.update(header+'.'+payload).digest('base64url');
console.log(header+'.'+payload+'.'+sig);
" 2>/dev/null)
if [ -n "$hs256_token" ]; then
check "HS256 token with PostgREST (backward compat)" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $hs256_token")"
else
echo " SKIP: Could not mint HS256 token (node required)"
fi
# ---------------------------------------------
# 4. JWT_KEYS format validation
# ---------------------------------------------
echo ""
echo "--- JWT_KEYS format ---"
JWT_KEYS_VAL=$(grep '^JWT_KEYS=' .env | cut -d= -f2-)
if [ -n "$JWT_KEYS_VAL" ]; then
# Auth expects a JSON array, not a JWKS object
jwt_keys_is_array=$(echo "$JWT_KEYS_VAL" | jq -r 'if type == "array" then "true" else "false" end' 2>/dev/null)
check "JWT_KEYS is JSON array (not JWKS object)" "true" "$jwt_keys_is_array"
jwt_keys_has_sign=$(echo "$JWT_KEYS_VAL" | jq -r 'if any(.[]; .key_ops and (.key_ops | index("sign"))) then "true" else "false" end' 2>/dev/null)
check "JWT_KEYS has a signing key (key_ops: sign)" "true" "$jwt_keys_has_sign"
else
echo " SKIP: JWT_KEYS not configured"
fi
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi

View File

@@ -0,0 +1,165 @@
#!/bin/sh
#
# Verify all self-hosted Supabase services started correctly by checking log output.
#
# Usage:
# sh test-container-logs.sh
#
# Prerequisites:
# - Running self-hosted Supabase instance (docker compose up)
#
set -e
pass=0
fail=0
project_name="${COMPOSE_PROJECT_NAME:-supabase}"
fail_msg() {
fail=$((fail + 1))
echo " FAIL: $1"
}
pass_msg() {
pass=$((pass + 1))
echo " PASS: $1"
}
# The `docker ps` fallback in the helpers below exists because the script
# doesn't know which compose `-f` flags the user ran `up` with. `docker compose
# ps` only sees services defined in the currently loaded compose files, but
# compose stamps `com.docker.compose.{project,service}` labels at `up` time -
# so a label-based lookup finds the container regardless of which override
# files are active in this shell.
is_service_running() {
service="$1"
if docker compose ps --services --status running 2>/dev/null | grep -q "^$service$"; then
return 0
fi
docker ps --filter "label=com.docker.compose.project=$project_name" \
--filter "label=com.docker.compose.service=$service" \
--filter "status=running" \
--quiet | grep -q '.'
}
get_container_id() {
service="$1"
container_id=$(docker compose ps -q "$service" 2>/dev/null || true)
if [ -n "$container_id" ]; then
printf '%s' "$container_id"
return
fi
container_id=$(docker ps -a \
--filter "label=com.docker.compose.project=$project_name" \
--filter "label=com.docker.compose.service=$service" \
--quiet)
set -- $container_id
printf '%s' "$1"
}
# Check that a service's logs contain all expected patterns
check_logs() {
service="$1"
shift
logs=$(docker compose logs "$service" 2>/dev/null || true)
if [ -z "$logs" ]; then
container_id=$(get_container_id "$service")
if [ -n "$container_id" ]; then
logs=$(docker logs "$container_id" 2>&1 || true)
fi
fi
if [ -z "$logs" ]; then
fail_msg "$service (no logs found)"
return
fi
for pattern in "$@"; do
if ! echo "$logs" | grep -q -i -E "$pattern"; then
fail_msg "$service (missing: $pattern)"
return
fi
done
pass_msg "$service"
}
check_logs_if_running() {
service="$1"
shift
if is_service_running "$service"; then
check_logs "$service" "$@"
else
pass_msg "$service (skipped: service not running)"
fi
}
echo ""
echo "=== Checking service startup logs ==="
echo ""
check_logs db \
'PostgreSQL init process complete; ready for start up.|Skipping initialization'
check_logs auth \
'db worker started'
check_logs_if_running kong \
'init.lua.*declarative config loaded'
check_logs_if_running api-gw \
'Envoy configuration generated successfully' \
'Starting Envoy...'
check_logs rest \
'Schema cache loaded in.*milliseconds'
check_logs realtime \
'Starting Realtime' \
'Connected to Postgres database' \
'Janitor started' \
'Starting MetricsCleaner'
check_logs storage \
'Started Successfully'
check_logs studio \
'ready in.*s$'
check_logs meta \
'Server listening at http'
check_logs functions \
'main function started'
check_logs_if_running analytics \
'Access LogflareWeb.Endpoint at http://localhost:4000' \
'Executing startup tasks' \
'Ensuring single tenant user is seeded'
check_logs supavisor \
'Connected to Postgres database' \
'HEAD /api/health$'
check_logs_if_running vector \
'Vector has started'
check_logs imgproxy \
'Starting server at :5001'
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
echo "Inspect logs: docker compose logs <service>"
echo ""
exit 1
fi

239
tests/test-pg17-upgrade.sh Normal file
View File

@@ -0,0 +1,239 @@
#!/bin/sh
#
# Test Postgres 15 -> 17 upgrade for self-hosted Supabase.
#
# Seeds test data on a running Postgres 15 stack, runs the upgrade script,
# and verifies data integrity + service connectivity using pgTAP.
#
# Usage:
# cd docker/
# sudo bash tests/test-pg17-upgrade.sh
#
# Prerequisites:
# - Running self-hosted Supabase with a clean, tests-only Postgres 15:
# docker compose up -d
# - .env file with POSTGRES_PASSWORD, ANON_KEY
#
set -eu
DB_CONTAINER="supabase-db"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the docker/ directory."
exit 1
fi
pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2-)
anon_key=$(grep '^ANON_KEY=' .env | cut -d '=' -f 2- || true)
if [ -z "$pg_password" ]; then
echo "Error: POSTGRES_PASSWORD not set in .env"
exit 1
fi
run_sql() {
docker exec -i \
-e PGPASSWORD="$pg_password" \
"$DB_CONTAINER" \
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@"
}
echo ""
echo "=== Postgres 15 -> 17 Upgrade Test ==="
echo ""
# --- Verify we're starting from Postgres 15 --------------------------------
current_version=$(run_sql -A -t -c "SHOW server_version;" | head -1)
case "$current_version" in
15.*) echo "Starting version: PostgreSQL $current_version" ;;
17.*) echo "Error: Already on Postgres 17. Start with a PG15 stack."; exit 1 ;;
*) echo "Error: Unexpected version: $current_version"; exit 1 ;;
esac
# --- Seed test data --------------------------------------------------------
# Note: this script is designed to run against a fresh docker-compose stack,
# not an existing database with user data.
echo ""
echo "Seeding test data on Postgres 15..."
run_sql <<'EOSQL'
-- Test table with various column types
CREATE TABLE IF NOT EXISTS public._upgrade_test (
id serial PRIMARY KEY,
name text NOT NULL,
value numeric(10,2),
created_at timestamptz DEFAULT now(),
metadata jsonb
);
TRUNCATE public._upgrade_test;
INSERT INTO public._upgrade_test (name, value, metadata) VALUES
('alpha', 1.50, '{"tag": "a"}'),
('bravo', 2.75, '{"tag": "b"}'),
('charlie', 3.00, '{"tag": "c"}'),
('delta', 4.25, '{"tag": "d"}'),
('echo', 5.99, '{"tag": "e"}');
-- Index
CREATE INDEX IF NOT EXISTS _upgrade_test_name_idx ON public._upgrade_test (name);
-- Function
CREATE OR REPLACE FUNCTION public._upgrade_test_fn(n int)
RETURNS int LANGUAGE sql IMMUTABLE AS $$
SELECT n * 2;
$$;
-- Grant access so PostgREST can read it
GRANT SELECT ON public._upgrade_test TO anon, authenticated;
EOSQL
pre_count=$(run_sql -A -t -c "SELECT count(*) FROM public._upgrade_test;" | tr -d '[:space:]')
pre_checksum=$(run_sql -A -t -c "SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test;" | tr -d '[:space:]')
echo " Rows: $pre_count"
echo " Checksum: $pre_checksum"
# --- Run upgrade -----------------------------------------------------------
echo ""
echo "Running upgrade script..."
echo ""
bash utils/upgrade-pg17.sh --yes
echo ""
# --- Verify with pgTAP ----------------------------------------------------
echo "Running pgTAP verification..."
echo ""
# Use a non-quoted heredoc so $pre_count and $pre_checksum are interpolated
run_sql <<EOSQL
CREATE EXTENSION IF NOT EXISTS pgtap;
SELECT plan(11);
-- Version
SELECT ok(version() LIKE 'PostgreSQL 17%', 'Running Postgres 17');
-- Table
SELECT has_table('public', '_upgrade_test', 'Test table survived upgrade');
-- Row count
SELECT is(
(SELECT count(*)::int FROM public._upgrade_test),
${pre_count},
'Row count preserved'
);
-- Data checksum
SELECT is(
(SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test),
'${pre_checksum}',
'Data checksum matches'
);
-- Index
SELECT has_index('public', '_upgrade_test', '_upgrade_test_name_idx', 'Index survived upgrade');
-- Function
SELECT has_function('public', '_upgrade_test_fn', ARRAY['integer'], 'Function survived upgrade');
SELECT is(public._upgrade_test_fn(21), 42, 'Function returns correct result');
-- Core extensions
-- Note: pgsodium may not be created as an extension in the postgres database
-- on default self-hosted installs (it's loaded via shared_preload_libraries
-- but the CREATE EXTENSION is conditional in the init migration).
SELECT ok(
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_net'),
'pg_net extension exists'
);
-- Roles
SELECT ok(
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_etl_admin'),
'supabase_etl_admin role exists'
);
SELECT ok(
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_read_only_user'),
'supabase_read_only_user role exists'
);
-- postgres is not superuser
SELECT ok(
NOT (SELECT rolsuper FROM pg_roles WHERE rolname = 'postgres'),
'postgres role is not superuser'
);
SELECT * FROM finish(true);
EOSQL
# --- Check service connectivity --------------------------------------------
echo ""
echo "Checking service connectivity..."
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
# PostgREST
if [ -n "$anon_key" ]; then
rest_status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "apikey: $anon_key" \
-H "Authorization: Bearer $anon_key" \
"http://localhost:8000/rest/v1/_upgrade_test?select=count" 2>/dev/null) || rest_status="000"
check "PostgREST connectivity" "200" "$rest_status"
fi
# Auth health (needs apikey header through Kong)
if [ -n "$anon_key" ]; then
auth_status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "apikey: $anon_key" \
"http://localhost:8000/auth/v1/health" 2>/dev/null) || auth_status="000"
check "Auth service health" "200" "$auth_status"
fi
echo ""
echo " Services: $pass passed, $fail failed"
# --- Clean up test artifacts ----------------------------------------------
echo ""
echo "Cleaning up test artifacts..."
run_sql <<'EOSQL' || true
DROP FUNCTION IF EXISTS public._upgrade_test_fn(int);
DROP TABLE IF EXISTS public._upgrade_test;
DROP EXTENSION IF EXISTS pgtap;
EOSQL
# --- Summary --------------------------------------------------------------
echo ""
if [ "$fail" -gt 0 ]; then
echo "=== SOME TESTS FAILED ==="
exit 1
fi
echo "=== Upgrade test passed ==="
echo ""
echo "To reclaim disk space:"
echo " rm -rf ./volumes/db/data.bak.pg15 ./volumes/db/pg17_upgrade_bin_*.tar.gz"
echo ""

358
tests/test-s3-backend.sh Normal file
View File

@@ -0,0 +1,358 @@
#!/bin/sh
#
# Test S3 backend directly, bypassing the Storage service.
#
# Validates that the S3-compatible backend (MinIO, RustFS, etc.) handles
# all S3 operations that Storage relies on. Uses the aws cli so the test
# is backend-agnostic — no vendor-specific tools required.
#
# Usage:
# sh test-s3-backend.sh # Uses localhost:9100
# sh test-s3-backend.sh <backend_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance with S3 backend + test override:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml \
# -f ./tests/docker-compose.s3.test.yml up -d
# - .env file with MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, GLOBAL_S3_BUCKET
# - aws cli v2
# - jq (for JSON parsing)
#
set -e
cleanup_files=""
trap 'rm -f $cleanup_files' EXIT
BACKEND_URL="${1:-http://localhost:${S3_BACKEND_TEST_PORT:-9100}}"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
for cmd in aws jq; do
if ! command -v $cmd >/dev/null 2>&1; then
echo "Error: $cmd not found."
exit 1
fi
done
# Read backend credentials from .env
BACKEND_ACCESS_KEY=$(grep '^MINIO_ROOT_USER=' .env | cut -d= -f2-)
BACKEND_SECRET_KEY=$(grep '^MINIO_ROOT_PASSWORD=' .env | cut -d= -f2-)
GLOBAL_S3_BUCKET=$(grep '^GLOBAL_S3_BUCKET=' .env | cut -d= -f2-)
REGION=$(grep '^REGION=' .env | cut -d= -f2-)
REGION="${REGION:-us-east-1}"
if [ -z "$BACKEND_ACCESS_KEY" ] || [ -z "$BACKEND_SECRET_KEY" ]; then
echo "Error: MINIO_ROOT_USER or MINIO_ROOT_PASSWORD not set in .env"
exit 1
fi
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
# Wrapper for aws commands against the backend directly
s3() {
AWS_ACCESS_KEY_ID="$BACKEND_ACCESS_KEY" \
AWS_SECRET_ACCESS_KEY="$BACKEND_SECRET_KEY" \
aws "$@" --endpoint-url "$BACKEND_URL" --region "$REGION" 2>&1
}
bucket_name="backend-test-$$"
echo ""
echo "=== S3 backend test against $BACKEND_URL ==="
echo ""
# ---------------------------------------------
# 1. ListBuckets (backend reachable)
# ---------------------------------------------
echo "--- Connectivity ---"
list_output=$(s3 s3api list-buckets --output json)
list_ok=$(echo "$list_output" | jq -r 'if .Buckets then "true" else "false" end' 2>/dev/null)
check "Backend reachable (ListBuckets)" "true" "$list_ok"
if [ "$list_ok" != "true" ]; then
echo " Cannot reach backend. Is the test override running?"
echo " Response: $list_output"
echo ""
echo "=== Results: $pass passed, $fail failed ==="
exit 1
fi
# ---------------------------------------------
# 2. Verify GLOBAL_S3_BUCKET exists
# ---------------------------------------------
echo ""
echo "--- Storage bucket ---"
if [ -n "$GLOBAL_S3_BUCKET" ]; then
storage_bucket_exists=$(s3 s3api list-buckets --output json | \
jq -r --arg name "$GLOBAL_S3_BUCKET" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null)
check "GLOBAL_S3_BUCKET ($GLOBAL_S3_BUCKET) exists" "true" "$storage_bucket_exists"
else
echo " SKIP: GLOBAL_S3_BUCKET not set"
fi
# ---------------------------------------------
# 3. CreateBucket
# ---------------------------------------------
echo ""
echo "--- CreateBucket ---"
s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null 2>&1
# Verify create succeeded
create_found=$(s3 s3api list-buckets --output json | \
jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null)
check "CreateBucket" "true" "$create_found"
if [ "$create_found" != "true" ]; then
echo " Cannot continue without a bucket. Aborting."
echo ""
echo "=== Results: $pass passed, $fail failed ==="
exit 1
fi
# Verify in ListBuckets (separate call)
bucket_found=$(s3 s3api list-buckets --output json | \
jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null)
check "Bucket visible in ListBuckets" "true" "$bucket_found"
# ---------------------------------------------
# 4. PutObject
# ---------------------------------------------
echo ""
echo "--- PutObject ---"
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
echo "hello from backend test" > "$tmpfile"
put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/test-file.txt")
put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false")
check "PutObject" "true" "$put_ok"
# ---------------------------------------------
# 5. ListObjectsV2
# ---------------------------------------------
echo ""
echo "--- ListObjectsV2 ---"
list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
object_found=$(echo "$list_objects" | \
jq -r '[.Contents[]? | .Key] | if any(. == "test-file.txt") then "true" else "false" end' 2>/dev/null)
check "Object found in ListObjectsV2" "true" "$object_found"
# ---------------------------------------------
# 6. HeadObject
# ---------------------------------------------
echo ""
echo "--- HeadObject ---"
head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "test-file.txt" --output json)
head_size=$(echo "$head_output" | jq -r '.ContentLength // 0' 2>/dev/null)
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
check "HeadObject returns correct size" "$original_size" "$head_size"
rm -f "$tmpfile"
# ---------------------------------------------
# 7. GetObject + content verify
# ---------------------------------------------
echo ""
echo "--- GetObject ---"
download_file=$(mktemp); cleanup_files="$cleanup_files $download_file"
s3 s3 cp "s3://$bucket_name/test-file.txt" "$download_file" >/dev/null
downloaded_content=$(cat "$download_file")
check "GetObject content matches" "hello from backend test" "$downloaded_content"
rm -f "$download_file"
# ---------------------------------------------
# 8. CopyObject
# ---------------------------------------------
echo ""
echo "--- CopyObject ---"
copy_output=$(s3 s3 cp "s3://$bucket_name/test-file.txt" "s3://$bucket_name/test-copy.txt")
copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false")
check "CopyObject" "true" "$copy_ok"
copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download"
s3 s3 cp "s3://$bucket_name/test-copy.txt" "$copy_download" >/dev/null
check "Copied object content matches" "hello from backend test" "$(cat "$copy_download")"
rm -f "$copy_download"
# ---------------------------------------------
# 9. DeleteObject
# ---------------------------------------------
echo ""
echo "--- DeleteObject ---"
s3 s3 rm "s3://$bucket_name/test-copy.txt" >/dev/null
list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
copy_gone=$(echo "$list_after_delete" | \
jq -r '[.Contents[]? | .Key] | if any(. == "test-copy.txt") then "false" else "true" end' 2>/dev/null)
check "Deleted object no longer listed" "true" "$copy_gone"
# ---------------------------------------------
# 10. Multipart upload (7MB)
# ---------------------------------------------
echo ""
echo "--- Multipart upload (7MB) ---"
large_file=$(mktemp); cleanup_files="$cleanup_files $large_file"
dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null
large_size=$(wc -c < "$large_file" | tr -d ' ')
large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin")
large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false")
check "Multipart upload (7MB)" "true" "$large_ok"
large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json)
remote_size=$(echo "$large_head" | jq -r '.ContentLength // 0' 2>/dev/null)
check "Multipart size matches ($large_size bytes)" "$large_size" "$remote_size"
large_download=$(mktemp); cleanup_files="$cleanup_files $large_download"
s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null
download_size=$(wc -c < "$large_download" | tr -d ' ')
check "Multipart download size matches" "$large_size" "$download_size"
rm -f "$large_file" "$large_download"
# ---------------------------------------------
# 11. DeleteObjects (batch delete)
# ---------------------------------------------
echo ""
echo "--- DeleteObjects (batch) ---"
batch_file=$(mktemp); cleanup_files="$cleanup_files $batch_file"
echo "batch-a" > "$batch_file"
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-a.txt" >/dev/null
echo "batch-b" > "$batch_file"
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-b.txt" >/dev/null
echo "batch-c" > "$batch_file"
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-c.txt" >/dev/null
rm -f "$batch_file"
delete_objects_output=$(s3 s3api delete-objects --bucket "$bucket_name" \
--delete '{"Objects":[{"Key":"batch-a.txt"},{"Key":"batch-b.txt"},{"Key":"batch-c.txt"}]}' \
--output json)
deleted_count=$(echo "$delete_objects_output" | jq -r '.Deleted | length' 2>/dev/null)
check "DeleteObjects removed 3 objects" "3" "$deleted_count"
# Verify all gone
batch_list=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --prefix "batch-" --output json)
batch_remaining=$(echo "$batch_list" | jq -r '[.Contents[]?] | length' 2>/dev/null)
check "Batch-deleted objects gone" "0" "$batch_remaining"
# ---------------------------------------------
# 12. Presigned URLs
# ---------------------------------------------
echo ""
echo "--- Presigned URLs ---"
presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file"
echo "presigned content test" > "$presign_file"
s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null
rm -f "$presign_file"
presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt")
presign_body=$(curl -s "$presigned_url")
check "Presigned URL returns correct content" "presigned content test" "$presign_body"
# ---------------------------------------------
# 13. Conditional request (IfNoneMatch)
# ---------------------------------------------
echo ""
echo "--- Conditional request (IfNoneMatch) ---"
# --if-none-match requires aws cli v2.22+ ; skip if not supported
if aws s3api put-object help 2>&1 | grep -q 'if-none-match'; then
cond_file=$(mktemp); cleanup_files="$cleanup_files $cond_file"
echo "conditional test" > "$cond_file"
# First put should succeed (key doesn't exist)
first_put_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \
--body "$cond_file" --if-none-match '*' --output json 2>&1 || true)
first_put_ok=$(echo "$first_put_err" | grep -qi "error\|denied\|PreconditionFailed" && echo "false" || echo "true")
check "IfNoneMatch put (new key) succeeds" "true" "$first_put_ok"
# Second put should fail with PreconditionFailed (key exists)
cond_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \
--body "$cond_file" --if-none-match '*' --output json 2>&1 || true)
cond_rejected=$(echo "$cond_err" | grep -qi "PreconditionFailed" && echo "true" || echo "false")
check "IfNoneMatch put (existing key) rejected" "true" "$cond_rejected"
rm -f "$cond_file"
else
echo " SKIP: aws cli does not support --if-none-match (requires v2.22+)"
fi
# ---------------------------------------------
# 14. Range request (partial GetObject)
# ---------------------------------------------
echo ""
echo "--- Range request ---"
range_file=$(mktemp); cleanup_files="$cleanup_files $range_file"
echo "hello range test content" > "$range_file"
s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null
rm -f "$range_file"
range_download=$(mktemp); cleanup_files="$cleanup_files $range_download"
s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \
--range "bytes=0-4" "$range_download" --output json >/dev/null
range_content=$(cat "$range_download")
check "Range request returns partial content" "hello" "$range_content"
rm -f "$range_download"
# ---------------------------------------------
# 15. Authentication
# ---------------------------------------------
echo ""
echo "--- Authentication ---"
bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \
AWS_SECRET_ACCESS_KEY="invalid-secret" \
aws s3api list-buckets \
--endpoint-url "$BACKEND_URL" \
--region "$REGION" \
--output json 2>&1 || true)
bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false")
check "Invalid credentials rejected" "true" "$bad_ok"
# ---------------------------------------------
# 16. Cleanup
# ---------------------------------------------
echo ""
echo "--- Cleanup ---"
s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null
s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null
bucket_gone=$(s3 s3api list-buckets --output json | \
jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end' 2>/dev/null)
check "Test bucket deleted" "true" "$bucket_gone"
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi

292
tests/test-s3.sh Normal file
View File

@@ -0,0 +1,292 @@
#!/bin/sh
#
# Test S3 protocol endpoint for self-hosted Supabase Storage.
#
# Verifies that the S3-compatible endpoint at /storage/v1/s3 works with
# standard S3 clients — the same way end users interact with it via
# aws cli, rclone, or other S3-compatible tools.
#
# Usage:
# sh test-s3.sh # Uses http://localhost:8000
# sh test-s3.sh <base_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance with S3 enabled:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
# - .env file with S3_PROTOCOL_ACCESS_KEY_ID, S3_PROTOCOL_ACCESS_KEY_SECRET, REGION
# - aws cli v2 (for S3 operations)
# - jq (for JSON parsing)
#
set -e
cleanup_files=""
trap 'rm -f $cleanup_files' EXIT
BASE_URL="${1:-http://localhost:8000}"
S3_ENDPOINT="$BASE_URL/storage/v1/s3"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
for cmd in aws jq; do
if ! command -v $cmd >/dev/null 2>&1; then
echo "Error: $cmd not found."
exit 1
fi
done
# Read keys from .env
S3_ACCESS_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_ID=' .env | cut -d= -f2-)
S3_SECRET_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_SECRET=' .env | cut -d= -f2-)
REGION=$(grep '^REGION=' .env | cut -d= -f2-)
if [ -z "$S3_ACCESS_KEY" ] || [ -z "$S3_SECRET_KEY" ]; then
echo "Error: S3_PROTOCOL_ACCESS_KEY_ID or S3_PROTOCOL_ACCESS_KEY_SECRET not set in .env"
exit 1
fi
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
# Wrapper for aws s3/s3api commands with correct endpoint and credentials
s3() {
AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
aws "$@" --endpoint-url "$S3_ENDPOINT" --region "$REGION" 2>&1
}
bucket_name="s3-test-$$"
echo ""
echo "=== S3 protocol test against $BASE_URL ==="
echo ""
# ---------------------------------------------
# 1. S3 ListBuckets
# ---------------------------------------------
echo "--- S3 ListBuckets ---"
list_output=$(s3 s3api list-buckets --output json)
list_ok=$(echo "$list_output" | jq -r 'if .Buckets then "true" else "false" end' 2>/dev/null)
check "ListBuckets returns valid response" "true" "$list_ok"
# ---------------------------------------------
# 2. S3 CreateBucket
# ---------------------------------------------
echo ""
echo "--- S3 CreateBucket ---"
s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null 2>&1
# Verify create succeeded
create_found=$(s3 s3api list-buckets --output json | \
jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null)
check "CreateBucket" "true" "$create_found"
if [ "$create_found" != "true" ]; then
echo " Cannot continue without a bucket. Aborting."
echo ""
echo "=== Results: $pass passed, $fail failed ==="
exit 1
fi
# Verify bucket appears in ListBuckets (separate call)
s3_bucket_found=$(s3 s3api list-buckets --output json | \
jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null)
check "Bucket visible in ListBuckets" "true" "$s3_bucket_found"
# ---------------------------------------------
# 3. S3 PutObject
# ---------------------------------------------
echo ""
echo "--- S3 PutObject ---"
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
echo "hello from s3 upload test" > "$tmpfile"
put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/s3-uploaded.txt")
put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false")
check "PutObject upload" "true" "$put_ok"
# ---------------------------------------------
# 4. S3 ListObjectsV2
# ---------------------------------------------
echo ""
echo "--- S3 ListObjectsV2 ---"
list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
object_found=$(echo "$list_objects" | \
jq -r '[.Contents[]? | .Key] | if any(. == "s3-uploaded.txt") then "true" else "false" end' 2>/dev/null)
check "Object found in ListObjectsV2" "true" "$object_found"
# ---------------------------------------------
# 5. S3 HeadObject
# ---------------------------------------------
echo ""
echo "--- S3 HeadObject ---"
head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "s3-uploaded.txt" --output json)
head_size=$(echo "$head_output" | jq -r '.ContentLength // 0' 2>/dev/null)
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
check "HeadObject returns correct size" "$original_size" "$head_size"
rm -f "$tmpfile"
# ---------------------------------------------
# 6. S3 GetObject (download) + content verify
# ---------------------------------------------
echo ""
echo "--- S3 GetObject ---"
download_file=$(mktemp); cleanup_files="$cleanup_files $download_file"
s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "$download_file" >/dev/null
downloaded_content=$(cat "$download_file")
check "GetObject content matches" "hello from s3 upload test" "$downloaded_content"
rm -f "$download_file"
# ---------------------------------------------
# 7. S3 CopyObject (server-side copy)
# ---------------------------------------------
echo ""
echo "--- S3 CopyObject ---"
copy_output=$(s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "s3://$bucket_name/s3-copied.txt")
copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false")
check "CopyObject" "true" "$copy_ok"
# Verify copied content
copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download"
s3 s3 cp "s3://$bucket_name/s3-copied.txt" "$copy_download" >/dev/null
check "Copied object content matches" "hello from s3 upload test" "$(cat "$copy_download")"
rm -f "$copy_download"
# ---------------------------------------------
# 8. S3 DeleteObject
# ---------------------------------------------
echo ""
echo "--- S3 DeleteObject ---"
s3 s3 rm "s3://$bucket_name/s3-copied.txt" >/dev/null
# Verify object is gone
list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
copied_gone=$(echo "$list_after_delete" | \
jq -r '[.Contents[]? | .Key] | if any(. == "s3-copied.txt") then "false" else "true" end' 2>/dev/null)
check "Deleted object no longer listed" "true" "$copied_gone"
# ---------------------------------------------
# 9. Multipart upload (>5MB triggers multipart)
# ---------------------------------------------
echo ""
echo "--- S3 multipart upload (7MB) ---"
large_file=$(mktemp); cleanup_files="$cleanup_files $large_file"
dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null
large_size=$(wc -c < "$large_file" | tr -d ' ')
large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin")
large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false")
check "Multipart upload (7MB)" "true" "$large_ok"
# Verify size via HeadObject
large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json)
remote_size=$(echo "$large_head" | jq -r '.ContentLength // 0' 2>/dev/null)
check "Multipart upload size matches ($large_size bytes)" "$large_size" "$remote_size"
# Download and verify size
large_download=$(mktemp); cleanup_files="$cleanup_files $large_download"
s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null
download_size=$(wc -c < "$large_download" | tr -d ' ')
check "Multipart download size matches" "$large_size" "$download_size"
rm -f "$large_file" "$large_download"
# ---------------------------------------------
# 10. Range request (partial GetObject)
# ---------------------------------------------
echo ""
echo "--- Range request ---"
range_file=$(mktemp); cleanup_files="$cleanup_files $range_file"
echo "hello range test content" > "$range_file"
s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null
rm -f "$range_file"
range_download=$(mktemp); cleanup_files="$cleanup_files $range_download"
s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \
--range "bytes=0-4" "$range_download" --output json >/dev/null
range_content=$(cat "$range_download")
check "Range request returns partial content" "hello" "$range_content"
rm -f "$range_download"
# ---------------------------------------------
# 11. Presigned URLs
# ---------------------------------------------
# Storage supports S3 presigned URLs (query-parameter auth), but Kong's
# request-transformer adds an empty Authorization header when the Lua
# expression evaluates to nil. Storage sees typeof "" === "string" and
# enters parseAuthorizationHeader instead of parseQuerySignature.
# This test will pass once the Kong config is fixed.
echo ""
echo "--- Presigned URLs ---"
presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file"
echo "presigned content test" > "$presign_file"
s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null
rm -f "$presign_file"
presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt")
presign_body=$(curl -s "$presigned_url")
check "Presigned URL returns correct content" "presigned content test" "$presign_body"
# ---------------------------------------------
# 12. Authentication
# ---------------------------------------------
echo ""
echo "--- Authentication ---"
bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \
AWS_SECRET_ACCESS_KEY="invalid-secret" \
aws s3api list-buckets \
--endpoint-url "$S3_ENDPOINT" \
--region "$REGION" \
--output json 2>&1 || true)
bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false")
check "Invalid credentials rejected" "true" "$bad_ok"
# ---------------------------------------------
# 13. Cleanup
# ---------------------------------------------
echo ""
echo "--- Cleanup ---"
s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null
s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null
# Verify bucket is gone
bucket_gone=$(s3 s3api list-buckets --output json | \
jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end' 2>/dev/null)
check "Bucket deleted via S3" "true" "$bucket_gone"
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi

351
tests/test-self-hosted.sh Normal file
View File

@@ -0,0 +1,351 @@
#!/bin/sh
#
# Smoke test for self-hosted Supabase - verifies core functionality end-to-end.
#
# Usage:
# sh test-self-hosted.sh # Uses http://localhost:8000
# sh test-self-hosted.sh <base_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance
# - .env file with keys configured
# - jq (for JSON parsing)
#
set -e
cleanup_files=""
trap 'rm -f $cleanup_files' EXIT
BASE_URL="${1:-http://localhost:8000}"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq not found. Install it: https://jqlang.github.io/jq/download/"
exit 1
fi
# Read keys from .env
ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-)
DASHBOARD_USERNAME=$(grep '^DASHBOARD_USERNAME=' .env | cut -d= -f2-)
DASHBOARD_PASSWORD=$(grep '^DASHBOARD_PASSWORD=' .env | cut -d= -f2-)
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
http_status() {
url="$1"
shift
curl -s -o /dev/null -w "%{http_code}" "$@" "$url"
}
http_body() {
url="$1"
shift
curl -s "$@" "$url"
}
echo ""
echo "=== Self-hosted smoke test against $BASE_URL ==="
echo ""
# ---------------------------------------------
# 1. Container health (via docker compose)
# ---------------------------------------------
echo "--- Container health ---"
if command -v docker >/dev/null 2>&1; then
container_status=$(docker compose ps --format json 2>/dev/null | jq -rs '
[.[] | select(.State != "running" or (.Health != "" and .Health != "healthy"))]
| (length | tostring) + "|" + ([.[] | .Service + ": State=" + .State + " Health=" + (.Health // "none")] | join(", "))
' 2>/dev/null || echo "?|")
unhealthy="${container_status%%|*}"
container_issues="${container_status#*|}"
if [ "$unhealthy" = "0" ]; then
check "All containers healthy" "0" "$unhealthy"
elif [ "$unhealthy" = "?" ]; then
echo " SKIP: Could not check container health"
else
check "All containers healthy ($container_issues)" "0" "$unhealthy"
fi
else
echo " SKIP: docker not available"
fi
# ---------------------------------------------
# 2. Studio dashboard
# ---------------------------------------------
echo ""
echo "--- Studio dashboard ---"
# Studio may redirect (307/302) after auth - follow redirects
check "Studio accessible with basic auth" "200" \
"$(http_status "$BASE_URL/" -L -u "$DASHBOARD_USERNAME:$DASHBOARD_PASSWORD")"
check "Studio rejects without auth" "401" \
"$(http_status "$BASE_URL/")"
# ---------------------------------------------
# 3. Auth: create user, sign in, get user, public signup, delete
# ---------------------------------------------
echo ""
echo "--- Auth: user lifecycle ---"
test_email="smoke-test-$$@example.com"
test_password="smoke-test-password-123456"
# Create user via admin API (works regardless of email autoconfirm setting)
create_resp=$(http_body "$BASE_URL/auth/v1/admin/users" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}")
user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null)
if [ -n "$user_id" ]; then
check "Create user (admin)" "true" "true"
# Sign in via public endpoint
signin_resp=$(http_body "$BASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}")
access_token=$(echo "$signin_resp" | jq -r '.access_token // empty' 2>/dev/null)
if [ -n "$access_token" ]; then
check "Sign in user" "true" "true"
# Get user profile with session JWT
check "Get user profile" "200" \
"$(http_status "$BASE_URL/auth/v1/user" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $access_token")"
else
check "Sign in user" "true" "false"
fi
# Delete user
delete_status=$(http_status "$BASE_URL/auth/v1/admin/users/$user_id" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "Delete user (admin)" "200" "$delete_status"
else
check "Create user (admin)" "true" "false"
fi
# Public signup (optional — depends on email autoconfirm setting)
signup_email="smoke-signup-$$@example.com"
signup_resp=$(http_body "$BASE_URL/auth/v1/signup" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$signup_email\",\"password\":\"$test_password\"}")
signup_token=$(echo "$signup_resp" | jq -r '.access_token // empty' 2>/dev/null)
signup_user_id=$(echo "$signup_resp" | jq -r '.id // .user.id // empty' 2>/dev/null)
if [ -n "$signup_token" ]; then
check "Public signup (autoconfirm on)" "true" "true"
else
echo " SKIP: Public signup (autoconfirm is off)"
fi
# Clean up signup user if created
if [ -n "$signup_user_id" ]; then
http_status "$BASE_URL/auth/v1/admin/users/$signup_user_id" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
fi
# ---------------------------------------------
# 4. PostgREST: query
# ---------------------------------------------
echo ""
echo "--- PostgREST ---"
check "REST API query" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY")"
# ---------------------------------------------
# 5. GraphQL
# ---------------------------------------------
echo ""
echo "--- GraphQL ---"
gql_resp=$(http_body "$BASE_URL/graphql/v1" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')
gql_has_data=$(echo "$gql_resp" | jq -r 'if .data then "true" else "false" end' 2>/dev/null)
check "GraphQL introspection" "true" "$gql_has_data"
# ---------------------------------------------
# 6. Storage: create bucket, upload >6MB file, download, cleanup
# ---------------------------------------------
echo ""
echo "--- Storage: bucket + file lifecycle ---"
bucket_name="smoke-test-$$"
# Create bucket
create_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"id\":\"$bucket_name\",\"name\":\"$bucket_name\",\"public\":true}")
check "Create bucket" "200" "$create_bucket_status"
if [ "$create_bucket_status" = "200" ]; then
# Generate a ~7MB file
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
dd if=/dev/urandom of="$tmpfile" bs=1048576 count=7 2>/dev/null
# Upload file
upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$tmpfile")
check "Upload 7MB file" "200" "$upload_status"
# Download file and verify size
download_size=$(curl -s \
"$BASE_URL/storage/v1/object/public/$bucket_name/test-large-file.bin" | wc -c | tr -d ' ')
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
check "Download file (size matches)" "true" \
"$([ "$download_size" = "$original_size" ] && echo true || echo false)"
rm -f "$tmpfile"
# Signed URL: upload a small file, create signed URL, fetch without auth
sign_upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: text/plain" \
--data-binary "signed url test content")
check "Upload file for signing" "200" "$sign_upload_status"
if [ "$sign_upload_status" = "200" ]; then
sign_resp=$(http_body "$BASE_URL/storage/v1/object/sign/$bucket_name/sign-test.txt" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d '{"expiresIn": 600}')
signed_path=$(echo "$sign_resp" | jq -r '.signedURL // empty' 2>/dev/null)
if [ -n "$signed_path" ]; then
check "Create signed URL" "true" "true"
# Fetch signed URL without any auth headers (goes through Kong)
signed_content=$(curl -s "$BASE_URL/storage/v1$signed_path")
check "Fetch signed URL (no auth)" "signed url test content" "$signed_content"
else
check "Create signed URL" "true" "false"
fi
fi
# Delete file
delete_file_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "Delete file" "200" "$delete_file_status"
# Delete signed test file
http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
# Delete bucket
delete_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket/$bucket_name" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "Delete bucket" "200" "$delete_bucket_status"
fi
# ---------------------------------------------
# 7. Edge Functions
# ---------------------------------------------
echo ""
echo "--- Edge Functions ---"
fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \
-X POST \
-H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{}')
check "Call hello function" '"Hello from Edge Functions!"' "$fn_resp"
# ---------------------------------------------
# 8. pg-meta (Studio backend)
# ---------------------------------------------
echo ""
echo "--- pg-meta ---"
check "pg-meta with service_role key" "200" \
"$(http_status "$BASE_URL/pg/schemas" \
-H "apikey: $SERVICE_ROLE_KEY")"
check "pg-meta rejects anon key" "403" \
"$(http_status "$BASE_URL/pg/schemas" \
-H "apikey: $ANON_KEY")"
check "pg-meta rejects no key" "401" \
"$(http_status "$BASE_URL/pg/schemas")"
echo ""
echo "--- MCP (blocked by default) ---"
check "/api/mcp blocked" "403" \
"$(http_status "$BASE_URL/api/mcp")"
check "/mcp blocked" "403" \
"$(http_status "$BASE_URL/mcp")"
# ---------------------------------------------
# 9. Realtime
# ---------------------------------------------
echo ""
echo "--- Realtime ---"
check "Realtime health" "true" \
"$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" \
-H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)"
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi