padhle - post migration
This commit is contained in:
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,398 @@
|
||||
#!/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 -> 403" "403" \
|
||||
"$(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 -> 403" "403" \
|
||||
"$(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, the API gateway 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 - use /api/ping to verify key auth (expect 200 with a valid key)
|
||||
check "Legacy ANON_KEY -> 200" "200" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $ANON_KEY")"
|
||||
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
check "New PUBLISHABLE_KEY -> 200" "200" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
|
||||
fi
|
||||
|
||||
check "No key -> 401" "401" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/ping")"
|
||||
|
||||
# Management endpoints must be blocked at the gateway (even with a valid key)
|
||||
check "/api/tenants blocked -> 403" "403" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")"
|
||||
check "/api/openapi blocked -> 403" "403" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/openapi" -H "apikey: $ANON_KEY")"
|
||||
|
||||
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)" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
|
||||
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")"
|
||||
|
||||
check "secret apikey + Authorization: Bearer sb_secret" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SUPABASE_SECRET_KEY" \
|
||||
-H "Authorization: Bearer $SUPABASE_SECRET_KEY")"
|
||||
fi
|
||||
|
||||
check "Legacy apikey + Authorization: Bearer <legacy jwt>" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Authorization: Bearer $ANON_KEY")"
|
||||
|
||||
check "Service role apikey + Authorization: Bearer <legacy jwt>" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_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" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
|
||||
check "Session JWT with PostgREST + service role key" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SERVICE_ROLE_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" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
check "Secret apikey + user JWT -> PostgREST allowed" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SUPABASE_SECRET_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)" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Authorization: Bearer $hs256_token")"
|
||||
check "HS256 token with PostgREST + service role key" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SERVICE_ROLE_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
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/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.
|
||||
# Logs are written to a temp file so that grep -q exits cleanly.
|
||||
check_logs() {
|
||||
service="$1"
|
||||
shift
|
||||
|
||||
logfile=$(mktemp)
|
||||
|
||||
docker compose logs "$service" > "$logfile" 2>/dev/null || true
|
||||
if [ ! -s "$logfile" ]; then
|
||||
container_id=$(get_container_id "$service")
|
||||
if [ -n "$container_id" ]; then
|
||||
docker logs "$container_id" > "$logfile" 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -s "$logfile" ]; then
|
||||
rm -f "$logfile"
|
||||
fail_msg "$service (no logs found)"
|
||||
return
|
||||
fi
|
||||
|
||||
for pattern in "$@"; do
|
||||
if ! grep -q -i -E "$pattern" "$logfile"; then
|
||||
rm -f "$logfile"
|
||||
fail_msg "$service (missing: $pattern)"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f "$logfile"
|
||||
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'
|
||||
|
||||
# API gateway: Envoy by default, or Kong when the kong override is enabled.
|
||||
# The service is named api-gw in both cases, so accept either startup marker.
|
||||
check_logs api-gw \
|
||||
'init\.lua.*declarative config loaded|Envoy configuration generated successfully'
|
||||
|
||||
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
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/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.
|
||||
# Postgres 17 is now the default, so start PG 15 explicitly via the override:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.pg15.yml 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 PG 15 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;
|
||||
|
||||
-- pg_cron: created here as supabase_admin (the common manually-enabled case),
|
||||
-- so the extension is owned by supabase_admin, NOT postgres. complete.sh's
|
||||
-- drop+recreate only fires for postgres-owned pg_cron, so this exercises the
|
||||
-- version reconcile in the upgrade script: Supabase PG 15 registers pg_cron
|
||||
-- as '1.6', while the target image packages it as '1.6.4' with no update
|
||||
-- path between them.
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN
|
||||
PERFORM cron.unschedule('upgrade_test_job');
|
||||
END IF;
|
||||
END $$;
|
||||
SELECT cron.schedule('upgrade_test_job', '5 4 * * *', 'SELECT 1');
|
||||
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"
|
||||
|
||||
# --- Seed a Vault secret ---------------------------------------------------
|
||||
# Verifies the pgsodium root key survives the volume swap/chown AND that Vault
|
||||
# secrets still decrypt on Postgres 17. For legacy (key_id-based) secrets this
|
||||
# also exercises complete.sh's pgsodium->Vault re-encryption; on a stock
|
||||
# self-hosted stack the secret is already pgsodium-less, so this confirms the
|
||||
# round-trip and the post-upgrade invariant (key_id IS NULL).
|
||||
VAULT_SECRET_NAME="upgrade_test_secret"
|
||||
VAULT_SECRET_VALUE="upgrade-test-secret-value-42"
|
||||
vault_available="f"
|
||||
if [ "$(run_sql -A -t -c "SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'supabase_vault');" | tr -d '[:space:]')" = "t" ]; then
|
||||
echo ""
|
||||
echo "Seeding Vault secret on Postgres 15..."
|
||||
run_sql <<EOSQL
|
||||
CREATE EXTENSION IF NOT EXISTS supabase_vault;
|
||||
-- Pre-clean so a re-run after a mid-run failure (where the trailing cleanup
|
||||
-- never executed) does not abort on the unique (name) index.
|
||||
DELETE FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}';
|
||||
SELECT vault.create_secret('${VAULT_SECRET_VALUE}', '${VAULT_SECRET_NAME}', 'pg17 upgrade test');
|
||||
EOSQL
|
||||
pre_secret=$(run_sql -A -t -c "SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = '${VAULT_SECRET_NAME}';" | tr -d '\n')
|
||||
if [ "$pre_secret" = "$VAULT_SECRET_VALUE" ]; then
|
||||
echo " Seeded '${VAULT_SECRET_NAME}' (decrypts correctly on PG 15)"
|
||||
else
|
||||
echo " Warning: seeded secret did not decrypt as expected on PG 15" >&2
|
||||
fi
|
||||
vault_available="t"
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping Vault seed: supabase_vault extension not available."
|
||||
fi
|
||||
|
||||
# --- Run upgrade -----------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "Running upgrade script..."
|
||||
echo ""
|
||||
|
||||
bash utils/upgrade-pg17.sh --yes
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Verify with pgTAP ----------------------------------------------------
|
||||
|
||||
echo "Running pgTAP verification..."
|
||||
echo ""
|
||||
|
||||
# Optional Vault assertions, only when a secret was seeded above.
|
||||
vault_plan=0
|
||||
vault_tests=""
|
||||
if [ "$vault_available" = "t" ]; then
|
||||
vault_plan=3
|
||||
vault_tests=$(cat <<EOSQL
|
||||
-- Vault secret survived the upgrade and still decrypts (root key intact).
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}'),
|
||||
'Vault secret survived upgrade'
|
||||
);
|
||||
SELECT is(
|
||||
(SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = '${VAULT_SECRET_NAME}'),
|
||||
'${VAULT_SECRET_VALUE}',
|
||||
'Vault secret still decrypts to original plaintext after upgrade'
|
||||
);
|
||||
SELECT ok(
|
||||
(SELECT key_id IS NULL FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}'),
|
||||
'Vault secret is in pgsodium-less format (key_id IS NULL) after upgrade'
|
||||
);
|
||||
EOSQL
|
||||
)
|
||||
fi
|
||||
total_plan=$((16 + vault_plan))
|
||||
|
||||
# Use a non-quoted heredoc so $pre_count, $pre_checksum, $total_plan and
|
||||
# $vault_tests are interpolated.
|
||||
run_sql <<EOSQL
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
SELECT plan(${total_plan});
|
||||
|
||||
-- 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'
|
||||
);
|
||||
|
||||
-- pg_cron: registered as 1.6 on PG 15; the upgrade must reconcile the version
|
||||
-- label to the target's packaged 1.6.4 and preserve scheduled jobs.
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron'),
|
||||
'pg_cron extension exists'
|
||||
);
|
||||
SELECT is(
|
||||
(SELECT extversion FROM pg_extension WHERE extname = 'pg_cron'),
|
||||
'1.6.4',
|
||||
'pg_cron version label reconciled to 1.6.4'
|
||||
);
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job'),
|
||||
'pg_cron scheduled job survived the upgrade'
|
||||
);
|
||||
|
||||
-- New predefined role (initdb-only migration; the target image's supautils.conf
|
||||
-- references it, so it must exist on an upgraded instance).
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_privileged_role'),
|
||||
'supabase_privileged_role exists'
|
||||
);
|
||||
|
||||
-- Extension version reconcile: complete.sh ran with .063 binaries, so the
|
||||
-- catalog should be reconciled to the target image's default version.
|
||||
SELECT is(
|
||||
(SELECT extversion FROM pg_extension WHERE extname = 'pg_net'),
|
||||
(SELECT default_version FROM pg_available_extensions WHERE name = 'pg_net'),
|
||||
'pg_net version reconciled to image default'
|
||||
);
|
||||
${vault_tests}
|
||||
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 the API gateway)
|
||||
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;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN
|
||||
PERFORM cron.unschedule('upgrade_test_job');
|
||||
END IF;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'supabase_vault') THEN
|
||||
DELETE FROM vault.secrets WHERE name = 'upgrade_test_secret';
|
||||
END IF;
|
||||
END $$;
|
||||
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 ""
|
||||
@@ -0,0 +1,371 @@
|
||||
#!/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.
|
||||
#
|
||||
# Always exits 0: under `set -e` a failing aws call would kill the suite on the
|
||||
# spot, so the check never records a FAIL and no summary is printed. The aws
|
||||
# error is echoed to stderr so a FAIL stays explainable even where the caller
|
||||
# discards stdout.
|
||||
s3() {
|
||||
s3_out=$(AWS_ACCESS_KEY_ID="$BACKEND_ACCESS_KEY" \
|
||||
AWS_SECRET_ACCESS_KEY="$BACKEND_SECRET_KEY" \
|
||||
aws "$@" --endpoint-url "$BACKEND_URL" --region "$REGION" 2>&1) ||
|
||||
echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2
|
||||
printf '%s\n' "$s3_out"
|
||||
}
|
||||
|
||||
# Wrapper for jq that yields empty output instead of aborting the suite when
|
||||
# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero.
|
||||
jq_r() {
|
||||
jq -r "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
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')
|
||||
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')
|
||||
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
|
||||
|
||||
# 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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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" || true)
|
||||
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')
|
||||
check "Test bucket deleted" "true" "$bucket_gone"
|
||||
|
||||
# ---------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
echo ""
|
||||
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/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.
|
||||
#
|
||||
# Always exits 0: under `set -e` a failing aws call would kill the suite on the
|
||||
# spot, so the check never records a FAIL and no summary is printed. The aws
|
||||
# error is echoed to stderr so a FAIL stays explainable even where the caller
|
||||
# discards stdout.
|
||||
s3() {
|
||||
s3_out=$(AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
|
||||
AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
|
||||
aws "$@" --endpoint-url "$S3_ENDPOINT" --region "$REGION" 2>&1) ||
|
||||
echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2
|
||||
printf '%s\n' "$s3_out"
|
||||
}
|
||||
|
||||
# Wrapper for jq that yields empty output instead of aborting the suite when
|
||||
# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero.
|
||||
jq_r() {
|
||||
jq -r "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
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')
|
||||
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
|
||||
|
||||
# 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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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).
|
||||
|
||||
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" || true)
|
||||
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')
|
||||
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
|
||||
@@ -0,0 +1,498 @@
|
||||
#!/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)
|
||||
# - sha256sum or shasum (for file integrity checks)
|
||||
#
|
||||
|
||||
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
|
||||
|
||||
# Portable file hash: prefers sha256sum (Linux), falls back to shasum (macOS)
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
file_hash() { sha256sum "$1" | awk '{print $1}'; }
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
file_hash() { shasum -a 256 "$1" | awk '{print $1}'; }
|
||||
else
|
||||
echo "Error: sha256sum or shasum not found."
|
||||
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 route with anon key" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $ANON_KEY")"
|
||||
|
||||
echo ""
|
||||
echo "--- PostgREST ---"
|
||||
check "REST API route with service role key" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY")"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 5. GraphQL
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- GraphQL (optional; off by default) ---"
|
||||
# pg_graphql is OFF by default since the PG17 image (the image drops the extension
|
||||
# on init, matching platform behavior for new projects), but users may enable it
|
||||
# (Studio extensions UI / CREATE EXTENSION pg_graphql). Both are valid states. A
|
||||
# healthy endpoint returns HTTP 200 either way:
|
||||
# enabled => {"data": ...}
|
||||
# disabled => {"errors":[{"message":"pg_graphql extension is not enabled."}]}
|
||||
# Assert the status AND the response shape, so a non-200, non-JSON, or empty body
|
||||
# (a real gateway/runtime failure) is not silently classified as "disabled".
|
||||
gql_status=$(http_status "$BASE_URL/graphql/v1" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"{ __typename }"}')
|
||||
gql_body=$(http_body "$BASE_URL/graphql/v1" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"{ __typename }"}')
|
||||
if [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.data' >/dev/null 2>&1; then
|
||||
gql_state="enabled"
|
||||
elif [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.errors' >/dev/null 2>&1; then
|
||||
gql_state="disabled"
|
||||
else
|
||||
gql_state="unhealthy (HTTP $gql_status)"
|
||||
fi
|
||||
case "$gql_state" in
|
||||
enabled | disabled) gql_health="healthy" ;;
|
||||
*) gql_health="unhealthy" ;;
|
||||
esac
|
||||
check "GraphQL endpoint healthy" "healthy" "$gql_health"
|
||||
echo " (GraphQL is $gql_state)"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 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 integrity
|
||||
download_tmp=$(mktemp); cleanup_files="$cleanup_files $download_tmp"
|
||||
curl -s "$BASE_URL/storage/v1/object/public/$bucket_name/test-large-file.bin" -o "$download_tmp"
|
||||
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
|
||||
download_size=$(wc -c < "$download_tmp" | tr -d ' ')
|
||||
check "Download file (size matches)" "$original_size" "$download_size"
|
||||
original_hash=$(file_hash "$tmpfile")
|
||||
download_hash=$(file_hash "$download_tmp")
|
||||
check "Download file (hash matches)" "$original_hash" "$download_hash"
|
||||
rm -f "$download_tmp"
|
||||
|
||||
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 the API gateway)
|
||||
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
|
||||
|
||||
# ---------------------------------------------
|
||||
# 6b. Storage: TUS resumable upload
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Storage: TUS resumable upload ---"
|
||||
|
||||
tus_bucket="smoke-tus-$$"
|
||||
|
||||
tus_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\":\"$tus_bucket\",\"name\":\"$tus_bucket\",\"public\":true}")
|
||||
check "TUS: create bucket" "200" "$tus_bucket_status"
|
||||
|
||||
if [ "$tus_bucket_status" = "200" ]; then
|
||||
# Generate a ~7MB file (above Studio's 6MB TUS threshold)
|
||||
tusfile=$(mktemp); cleanup_files="$cleanup_files $tusfile"
|
||||
dd if=/dev/urandom of="$tusfile" bs=1048576 count=7 2>/dev/null
|
||||
tus_file_size=$(wc -c < "$tusfile" | tr -d ' ')
|
||||
tus_chunk_size=$((4 * 1048576)) # 4MB first chunk
|
||||
|
||||
# Encode TUS metadata values as base64
|
||||
tus_bucket_b64=$(printf '%s' "$tus_bucket" | base64)
|
||||
tus_object_b64=$(printf '%s' "tus-test-file.bin" | base64)
|
||||
tus_mime_b64=$(printf '%s' "application/octet-stream" | base64)
|
||||
|
||||
# 1. Create resumable upload
|
||||
tus_create_resp=$(curl -s -i -X POST \
|
||||
"$BASE_URL/storage/v1/upload/resumable" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Tus-Resumable: 1.0.0" \
|
||||
-H "Upload-Length: $tus_file_size" \
|
||||
-H "Upload-Metadata: bucketName $tus_bucket_b64,objectName $tus_object_b64,contentType $tus_mime_b64" \
|
||||
-H "x-upsert: true")
|
||||
tus_create_status=$(echo "$tus_create_resp" | grep -m1 '^HTTP/' | grep -o '[0-9][0-9][0-9]')
|
||||
# Supabase Storage always returns an absolute Location URL (see generateUrl in storage/src/http/routes/tus/lifecycle.ts)
|
||||
tus_location=$(echo "$tus_create_resp" | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//')
|
||||
check "TUS: create resumable upload" "201" "$tus_create_status"
|
||||
|
||||
if [ -n "$tus_location" ]; then
|
||||
# 2. Upload first chunk (0 to 4MB)
|
||||
tus_chunk1_status=$(dd if="$tusfile" bs=1048576 count=4 2>/dev/null | \
|
||||
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
|
||||
"$tus_location" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Tus-Resumable: 1.0.0" \
|
||||
-H "Upload-Offset: 0" \
|
||||
-H "Content-Type: application/offset+octet-stream" \
|
||||
--data-binary @-)
|
||||
check "TUS: upload chunk 1 (4MB)" "204" "$tus_chunk1_status"
|
||||
|
||||
# 3. Upload second chunk (4MB to end)
|
||||
tus_remaining=$((tus_file_size - tus_chunk_size))
|
||||
tus_chunk2_status=$(dd if="$tusfile" bs=1048576 skip=4 count=3 2>/dev/null | \
|
||||
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
|
||||
"$tus_location" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Tus-Resumable: 1.0.0" \
|
||||
-H "Upload-Offset: $tus_chunk_size" \
|
||||
-H "Content-Type: application/offset+octet-stream" \
|
||||
--data-binary @-)
|
||||
check "TUS: upload chunk 2 (remaining)" "204" "$tus_chunk2_status"
|
||||
|
||||
# 4. Verify download matches original (hash check proves correct chunk reassembly)
|
||||
tus_download_tmp=$(mktemp); cleanup_files="$cleanup_files $tus_download_tmp"
|
||||
curl -s "$BASE_URL/storage/v1/object/public/$tus_bucket/tus-test-file.bin" -o "$tus_download_tmp"
|
||||
tus_download_size=$(wc -c < "$tus_download_tmp" | tr -d ' ')
|
||||
check "TUS: download size matches" "$tus_file_size" "$tus_download_size"
|
||||
tus_original_hash=$(file_hash "$tusfile")
|
||||
tus_download_hash=$(file_hash "$tus_download_tmp")
|
||||
check "TUS: download hash matches" "$tus_original_hash" "$tus_download_hash"
|
||||
rm -f "$tus_download_tmp"
|
||||
fi
|
||||
|
||||
rm -f "$tusfile"
|
||||
|
||||
# Cleanup: delete file and bucket
|
||||
http_status "$BASE_URL/storage/v1/object/$tus_bucket/tus-test-file.bin" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
|
||||
|
||||
tus_delete_bucket=$(http_status "$BASE_URL/storage/v1/bucket/$tus_bucket" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
|
||||
check "TUS: delete bucket" "200" "$tus_delete_bucket"
|
||||
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 (ping)" "200" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/ping" \
|
||||
-H "apikey: $ANON_KEY")"
|
||||
|
||||
# Management endpoints must be blocked at the gateway (even with a valid key)
|
||||
check "Realtime /api/tenants blocked" "403" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/tenants" \
|
||||
-H "apikey: $ANON_KEY")"
|
||||
check "Realtime /api/openapi blocked" "403" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/openapi" \
|
||||
-H "apikey: $ANON_KEY")"
|
||||
|
||||
# ---------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
echo ""
|
||||
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,380 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Hermetic test for update.sh (the self-hosted in-place update script).
|
||||
#
|
||||
# Builds a tiny synthetic "upstream" git repo with two tagged releases
|
||||
# (self-hosted/v0.9.0 -> self-hosted/v1.1.0; 1.0.0 exists only as a manifest key),
|
||||
# where the target ships a breaking-change manifest with entries at the base and
|
||||
# inside the window. It then simulates a configured deployment based on v0.9.0
|
||||
# and runs update.sh via a SUPABASE_REPO_URL override (no network), asserting:
|
||||
# the 3-way merge preserves secrets/data/overrides, adds new .env keys (but not
|
||||
# ones the user commented out), applies clean merges, reports real conflicts,
|
||||
# honors .gitignore for user-owned paths, surfaces only the in-window gate
|
||||
# entries (base-version entry excluded), and advances the stamp ONLY on a clean
|
||||
# apply. Also covers the clean-apply path, default-target resolution, --from,
|
||||
# --dry-run, the missing-stamp report-only path, and a malformed manifest being
|
||||
# refused.
|
||||
#
|
||||
# Usage:
|
||||
# sh tests/test-update.sh # run from the docker/ directory
|
||||
#
|
||||
|
||||
set -eu
|
||||
|
||||
# Isolate from the developer's global/system git config (gpgsign, hooksPath,
|
||||
# templateDir, core.excludesfile) so neither the synthetic commits nor update.sh's
|
||||
# internal git calls (fetch, merge-file, check-ignore) are affected. Exported so
|
||||
# the update.sh subprocess inherits them too.
|
||||
export GIT_CONFIG_GLOBAL=/dev/null
|
||||
export GIT_CONFIG_NOSYSTEM=1
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
DOCKER_DIR=$(dirname "$SCRIPT_DIR")
|
||||
UPDATE_SH="$DOCKER_DIR/update.sh"
|
||||
|
||||
[ -f "$UPDATE_SH" ] || { echo "ERROR: $UPDATE_SH not found"; exit 1; }
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; }
|
||||
bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; }
|
||||
|
||||
assert_file_contains() { # <file> <pattern> <desc>
|
||||
if grep -qF "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (missing '$2' in $1)"; fi
|
||||
}
|
||||
assert_file_missing_pattern() { # <file> <pattern> <desc>
|
||||
if grep -qF "$2" "$1" 2>/dev/null; then bad "$3 (unexpected '$2' in $1)"; else ok "$3"; fi
|
||||
}
|
||||
assert_line() { # <file> <ERE> <desc>
|
||||
if grep -qE "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (no line matching /$2/ in $1)"; fi
|
||||
}
|
||||
assert_no_line() { # <file> <ERE> <desc>
|
||||
if grep -qE "$2" "$1" 2>/dev/null; then bad "$3 (unexpected line matching /$2/ in $1)"; else ok "$3"; fi
|
||||
}
|
||||
assert_path_exists() { # <path> <desc>
|
||||
if [ -e "$1" ]; then ok "$2"; else bad "$2 ($1 missing)"; fi
|
||||
}
|
||||
assert_path_absent() { # <path> <desc>
|
||||
if [ -e "$1" ]; then bad "$2 ($1 should not exist)"; else ok "$2"; fi
|
||||
}
|
||||
|
||||
# portable in-place sed (BSD + GNU): sedi <expr> <file>
|
||||
sedi() { sed "$1" "$2" > "$2.tmp" && mv "$2.tmp" "$2"; }
|
||||
|
||||
# --- 1. Build the synthetic upstream repo -----------------------------------
|
||||
|
||||
SRC="$WORK/upstream"
|
||||
mkdir -p "$SRC/docker/volumes/api"
|
||||
cd "$SRC"
|
||||
git init -q
|
||||
git config user.email t@t.t
|
||||
git config user.name t
|
||||
|
||||
cat > docker/docker-compose.yml <<'EOF'
|
||||
services:
|
||||
studio:
|
||||
image: supabase/studio:OLD
|
||||
db:
|
||||
image: supabase/postgres:15
|
||||
EOF
|
||||
cat > docker/.env.example <<'EOF'
|
||||
POSTGRES_PASSWORD=changeme
|
||||
JWT_SECRET=changeme
|
||||
KEEP_ME=base-default
|
||||
EOF
|
||||
printf 'base kong\n' > docker/volumes/api/kong.yml
|
||||
printf 'remove me\n' > docker/old-only.txt
|
||||
cp "$DOCKER_DIR/.gitignore" docker/.gitignore
|
||||
mkdir -p docker/volumes/functions/main
|
||||
printf 'base main\n' > docker/volumes/functions/main/index.ts
|
||||
git add -A && git commit -qm base && git tag self-hosted/v0.9.0
|
||||
|
||||
# target commit
|
||||
cat > docker/docker-compose.yml <<'EOF'
|
||||
services:
|
||||
studio:
|
||||
image: supabase/studio:NEW
|
||||
db:
|
||||
image: supabase/postgres:17
|
||||
EOF
|
||||
cat > docker/.env.example <<'EOF'
|
||||
POSTGRES_PASSWORD=changeme
|
||||
JWT_SECRET=changeme
|
||||
KEEP_ME=base-default
|
||||
NEW_KEY=new-default
|
||||
EOF
|
||||
printf 'brand new\n' > docker/new-only.txt
|
||||
printf 'target main\n' > docker/volumes/functions/main/index.ts
|
||||
mkdir -p docker/volumes/functions/hello
|
||||
printf 'target hello\n' > docker/volumes/functions/hello/index.ts
|
||||
# A gitignored sample under volumes/snippets shipped by upstream: must NOT
|
||||
# overwrite the user's file of the same name (exercises is_excluded directly).
|
||||
mkdir -p docker/volumes/snippets
|
||||
printf 'VENDOR SEED\n' > docker/volumes/snippets/seed.sql
|
||||
# Manifest with entries at/below and inside the window:
|
||||
# 0.9.0 == the base -> must be EXCLUDED (window is half-open: (base, target]).
|
||||
# 1.0.0 inside -> surfaces (carries requires+gate).
|
||||
# 1.1.0 == target -> surfaces; requires-less and sorts LAST (no _schema after),
|
||||
# guarding the set -e regression where a requires-less final entry
|
||||
# aborted the script. Do NOT add a version key after 1.1.0.
|
||||
cat > docker/upgrades.json <<'EOF'
|
||||
{
|
||||
"0.9.0": {
|
||||
"breaking": true
|
||||
},
|
||||
"1.0.0": {
|
||||
"breaking": true,
|
||||
"gate": "utils/demo-migrate.sh",
|
||||
"migration_guide_url": "https://example.test/guide",
|
||||
"requires": ["Run the demo migration first."]
|
||||
},
|
||||
"1.1.0": {
|
||||
"breaking": true
|
||||
}
|
||||
}
|
||||
EOF
|
||||
git rm -q docker/old-only.txt
|
||||
git add -A
|
||||
git add -f docker/volumes/functions/hello/index.ts docker/volumes/snippets/seed.sql
|
||||
# Release tag for the target / default-target (latest self-hosted/v*) path.
|
||||
git commit -qm target && git tag self-hosted/v1.1.0
|
||||
|
||||
# A ref whose upgrades.json is valid JSON but NOT an object. update.sh must
|
||||
# refuse (die) rather than silently skip the gate. Named so latest_release_tag
|
||||
# ignores it (not self-hosted/v*), leaving the default-target path on v1.1.0.
|
||||
printf '["valid JSON, but not an object"]\n' > docker/upgrades.json
|
||||
git add -A && git commit -qm 'malformed manifest' && git tag malformed-manifest
|
||||
|
||||
# --- helper: lay down a deployment based on v0.9.0 --------------------------
|
||||
# make_deploy <dir> [conflict] - pass "conflict" to pin the studio image so the
|
||||
# merge produces a real conflict; omit for a clean apply.
|
||||
|
||||
make_deploy() { # <dir> [conflict]
|
||||
d="$1"
|
||||
_mode="${2:-clean}"
|
||||
mkdir -p "$d"
|
||||
git -C "$SRC" archive self-hosted/v0.9.0 docker | tar -x -C "$d" --strip-components=1
|
||||
cp "$UPDATE_SH" "$d/update.sh"
|
||||
# configured .env: real secret, an extra user key, and KEEP_ME commented out
|
||||
# on purpose (must NOT be re-added by the .env key-union).
|
||||
cp "$d/.env.example" "$d/.env"
|
||||
sedi "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=test-secret-123/" "$d/.env"
|
||||
sedi "s/^KEEP_ME=/#KEEP_ME=/" "$d/.env"
|
||||
printf 'EXTRA_USER_KEY=mine\n' >> "$d/.env"
|
||||
# user-owned override (must never be touched)
|
||||
printf 'services: {}\n# my override\n' > "$d/docker-compose.override.yml"
|
||||
# data dirs with sentinels (must never be touched)
|
||||
mkdir -p "$d/volumes/db/data" "$d/volumes/storage"
|
||||
printf 'DBDATA\n' > "$d/volumes/db/data/keep.txt"
|
||||
printf 'OBJ\n' > "$d/volumes/storage/keep.txt"
|
||||
# user adds a line to kong (upstream unchanged -> clean merge, must survive)
|
||||
printf 'user added line\n' >> "$d/volumes/api/kong.yml"
|
||||
# user-owned paths per .gitignore (must not be touched by the merge)
|
||||
mkdir -p "$d/volumes/snippets" "$d/volumes/functions/my-fn"
|
||||
printf 'USER_SNIPPET\n' > "$d/volumes/snippets/user.sql"
|
||||
printf 'user fn\n' > "$d/volumes/functions/my-fn/index.ts"
|
||||
# user file at the SAME path as the vendor snippet shipped at target:
|
||||
# is_excluded must skip it so the user's content survives.
|
||||
printf 'USER SEED\n' > "$d/volumes/snippets/seed.sql"
|
||||
# legacy sample fn in snapshot at target but gitignored - must not overwrite
|
||||
mkdir -p "$d/volumes/functions/hello"
|
||||
printf 'user hello\n' > "$d/volumes/functions/hello/index.ts"
|
||||
# version stamp pointing at the base (ref only; update.sh derives the rest)
|
||||
printf 'ref=self-hosted/v0.9.0\n' > "$d/.supabase-version"
|
||||
if [ "$_mode" = "conflict" ]; then
|
||||
# user pins the studio image (same line upstream changes -> conflict)
|
||||
sedi "s#supabase/studio:OLD#supabase/studio:USER-PINNED#" "$d/docker-compose.yml"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: apply path (with a conflict) ==="
|
||||
|
||||
DEPLOY="$WORK/deploy"
|
||||
make_deploy "$DEPLOY" conflict
|
||||
cd "$DEPLOY"
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/apply.log" 2>&1 || rc=$?
|
||||
|
||||
sed 's/^/ | /' "$WORK/apply.log"
|
||||
|
||||
if [ "$rc" = "2" ]; then ok "exit status 2 signals conflicts"; else bad "expected exit 2 (conflicts), got $rc"; fi
|
||||
assert_file_contains ".env" "POSTGRES_PASSWORD=test-secret-123" "user secret preserved"
|
||||
assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended"
|
||||
assert_file_contains ".env" "EXTRA_USER_KEY=mine" "extra user key kept"
|
||||
assert_line ".env" "^#KEEP_ME=base-default" "user's commented key left commented"
|
||||
assert_no_line ".env" "^KEEP_ME=" "commented .env key not re-added uncommented"
|
||||
assert_file_contains "docker-compose.override.yml" "my override" "override untouched"
|
||||
assert_file_contains "volumes/db/data/keep.txt" "DBDATA" "db data untouched"
|
||||
assert_file_contains "volumes/storage/keep.txt" "OBJ" "storage untouched"
|
||||
assert_file_contains "docker-compose.yml" "<<<<<<<" "conflict open marker written"
|
||||
assert_file_contains "docker-compose.yml" "=======" "conflict separator written"
|
||||
assert_file_contains "docker-compose.yml" ">>>>>>>" "conflict close marker written"
|
||||
assert_file_contains "docker-compose.yml" "USER-PINNED" "user value present in conflict"
|
||||
assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "upstream value present in conflict"
|
||||
assert_file_contains "docker-compose.yml" "supabase/postgres:17" "non-conflicting line merged (pg17)"
|
||||
assert_path_exists "new-only.txt" "new upstream file added"
|
||||
assert_path_exists "old-only.txt" "removed-upstream file left in place"
|
||||
assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "stamp NOT advanced on conflict"
|
||||
assert_file_contains "$WORK/apply.log" "Files with merge conflicts" "conflicts reported in summary"
|
||||
assert_file_contains "$WORK/apply.log" "[1.0.0]" "manifest entry 1.0.0 surfaced"
|
||||
assert_file_contains "$WORK/apply.log" "[1.1.0] BREAKING" "requires-less last entry surfaced (no set -e abort)"
|
||||
assert_file_missing_pattern "$WORK/apply.log" "[0.9.0]" "base-version entry excluded (window lower bound is half-open)"
|
||||
assert_file_contains "$WORK/apply.log" "Run the demo migration first." "manifest gate step surfaced"
|
||||
assert_file_contains "$WORK/apply.log" "utils/demo-migrate.sh" "manifest gate script surfaced"
|
||||
assert_file_contains "$WORK/apply.log" "example.test/guide" "manifest migration guide surfaced"
|
||||
assert_file_contains "$WORK/apply.log" "gone from the new .env.example" ".env key-removal section shown"
|
||||
assert_file_contains "$WORK/apply.log" "EXTRA_USER_KEY" "removed .env key listed in report"
|
||||
if ls backups/*.tgz >/dev/null 2>&1; then
|
||||
ok "backup archive created"
|
||||
for _bk in backups/*.tgz; do break; done
|
||||
tar tzf "$_bk" > "$WORK/bk.list" 2>/dev/null || true
|
||||
assert_file_contains "$WORK/bk.list" ".env" "backup includes .env"
|
||||
assert_file_missing_pattern "$WORK/bk.list" "volumes/db/data" "backup excludes db data dir"
|
||||
assert_file_missing_pattern "$WORK/bk.list" "volumes/storage" "backup excludes storage dir"
|
||||
assert_file_missing_pattern "$WORK/bk.list" "backups/" "backup excludes backups dir"
|
||||
else
|
||||
bad "no backup archive"
|
||||
fi
|
||||
assert_file_contains "volumes/snippets/user.sql" "USER_SNIPPET" "snippets left untouched"
|
||||
# seed.sql and hello/index.ts are the only files that are BOTH shipped in the
|
||||
# target snapshot AND gitignored, so they are the real is_excluded coverage.
|
||||
# Assert the user's content survives AND no vendor content / conflict markers
|
||||
# leaked in - i.e. the file was skipped, not merged/conflicted.
|
||||
assert_file_contains "volumes/snippets/seed.sql" "USER SEED" "gitignored snippet: user content kept"
|
||||
assert_file_missing_pattern "volumes/snippets/seed.sql" "VENDOR SEED" "gitignored snippet: no vendor content"
|
||||
assert_file_missing_pattern "volumes/snippets/seed.sql" "<<<<<<<" "gitignored snippet: not conflicted (skipped)"
|
||||
assert_file_contains "volumes/functions/my-fn/index.ts" "user fn" "custom edge fn left untouched"
|
||||
assert_file_contains "volumes/functions/main/index.ts" "target main" "vendor main/index.ts updated"
|
||||
assert_file_contains "volumes/functions/hello/index.ts" "user hello" "gitignored fn: user content kept"
|
||||
assert_file_missing_pattern "volumes/functions/hello/index.ts" "target hello" "gitignored fn: no vendor content"
|
||||
assert_file_missing_pattern "volumes/functions/hello/index.ts" "<<<<<<<" "gitignored fn: not conflicted (skipped)"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: clean apply (no conflict) advances the stamp and exits 0 ==="
|
||||
|
||||
CLEAN="$WORK/clean"
|
||||
make_deploy "$CLEAN"
|
||||
cd "$CLEAN"
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/clean.log" 2>&1 || rc=$?
|
||||
if [ "$rc" = "0" ]; then ok "clean apply exits 0"; else bad "clean apply expected exit 0, got $rc"; fi
|
||||
assert_file_contains "$WORK/clean.log" "Update applied cleanly." "clean apply announced"
|
||||
assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "clean apply wrote no conflict markers"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced on clean apply"
|
||||
assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended (clean)"
|
||||
assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "vendor file updated (clean)"
|
||||
assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line (clean)"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: default target resolves to latest self-hosted/v* tag ==="
|
||||
|
||||
TAGD="$WORK/tagdefault"
|
||||
make_deploy "$TAGD"
|
||||
cd "$TAGD"
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --yes > "$WORK/tag.log" 2>&1 || true
|
||||
assert_file_contains "$WORK/tag.log" "Latest release tag: self-hosted/v1.1.0" "resolved latest release tag (no --to)"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced to the tag"
|
||||
assert_file_contains ".env" "NEW_KEY=new-default" "update applied via default target"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: --from supplies the base when the stamp is missing ==="
|
||||
|
||||
FROMD="$WORK/fromd"
|
||||
make_deploy "$FROMD"
|
||||
cd "$FROMD"
|
||||
rm -f .supabase-version
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --from self-hosted/v0.9.0 --to self-hosted/v1.1.0 --yes > "$WORK/from.log" 2>&1 || rc=$?
|
||||
assert_file_missing_pattern "$WORK/from.log" "REPORT-ONLY" "--from performs a real update (not report-only)"
|
||||
assert_file_contains ".env" "NEW_KEY=new-default" "--from applied the update"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "--from advanced the stamp"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: --dry-run writes nothing ==="
|
||||
|
||||
DRYD="$WORK/dry"
|
||||
make_deploy "$DRYD" conflict
|
||||
cd "$DRYD"
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --dry-run > "$WORK/dry.log" 2>&1 || true
|
||||
assert_file_missing_pattern ".env" "NEW_KEY" "dry-run did not append env key"
|
||||
assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "dry-run did not write conflict"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "dry-run left stamp unchanged"
|
||||
assert_path_absent "backups" "dry-run took no backup"
|
||||
assert_file_contains "$WORK/dry.log" "DRY RUN" "dry-run labeled output"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: missing stamp -> report-only (still surfaces the gate) ==="
|
||||
|
||||
MISS="$WORK/miss"
|
||||
make_deploy "$MISS"
|
||||
cd "$MISS"
|
||||
rm -f .supabase-version
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 > "$WORK/miss.log" 2>&1 || rc=$?
|
||||
if [ "$rc" = "0" ]; then ok "report-only exits 0"; else bad "report-only expected exit 0, got $rc"; fi
|
||||
assert_file_contains "$WORK/miss.log" "REPORT-ONLY" "report-only mode announced"
|
||||
assert_file_missing_pattern ".env" "NEW_KEY" "report-only wrote nothing to .env"
|
||||
assert_path_absent "backups" "report-only took no backup"
|
||||
assert_file_contains "$WORK/miss.log" "Breaking changes / required manual steps" "report-only surfaces the gate"
|
||||
assert_file_contains "$WORK/miss.log" "[1.0.0]" "report-only lists in-range breaking release"
|
||||
assert_file_contains "$WORK/miss.log" "[0.9.0]" "report-only (open lower bound) includes the base-version entry"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: malformed manifest -> refuses (dies), writes nothing ==="
|
||||
|
||||
BADM="$WORK/badmanifest"
|
||||
make_deploy "$BADM"
|
||||
cd "$BADM"
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to malformed-manifest --yes > "$WORK/bad.log" 2>&1 || rc=$?
|
||||
if [ "$rc" != "0" ] && [ "$rc" != "2" ]; then ok "malformed manifest aborts (die, not a normal exit)"; else bad "expected die (non-0, non-2), got $rc"; fi
|
||||
assert_file_contains "$WORK/bad.log" "not a valid JSON object" "malformed-manifest error surfaced"
|
||||
assert_file_missing_pattern ".env" "NEW_KEY" "malformed manifest: .env untouched"
|
||||
assert_path_absent "backups" "malformed manifest: no backup taken"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "malformed manifest: stamp not advanced"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: never overwrites the running script; stages the target's copy as .dist ==="
|
||||
|
||||
# A dedicated upstream whose docker/ ships an update.sh that DIFFERS from the one
|
||||
# we run, so the merge must divert it to update.sh.dist rather than rewrite the
|
||||
# live script mid-run (which would corrupt this process).
|
||||
SELFSRC="$WORK/selfsrc"
|
||||
mkdir -p "$SELFSRC/docker"
|
||||
cd "$SELFSRC"
|
||||
git init -q
|
||||
git config user.email t@t.t
|
||||
git config user.name t
|
||||
printf 'services:\n db:\n image: x\n' > docker/docker-compose.yml
|
||||
printf 'KEEP=1\n' > docker/.env.example
|
||||
cp "$DOCKER_DIR/.gitignore" docker/.gitignore
|
||||
printf '#!/bin/sh\necho THIS-IS-THE-NEW-UPDATE-SH\n' > docker/update.sh
|
||||
git add -A && git commit -qm self && git tag self-hosted/v2.0.0
|
||||
|
||||
SELFDEP="$WORK/selfdep"
|
||||
mkdir -p "$SELFDEP"
|
||||
printf 'services:\n db:\n image: x\n' > "$SELFDEP/docker-compose.yml"
|
||||
printf 'KEEP=1\n' > "$SELFDEP/.env"
|
||||
cp "$UPDATE_SH" "$SELFDEP/update.sh" # the running script = the real update.sh
|
||||
printf 'ref=self-hosted/v2.0.0\n' > "$SELFDEP/.supabase-version"
|
||||
cd "$SELFDEP"
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SELFSRC" sh ./update.sh --to self-hosted/v2.0.0 --yes > "$WORK/self.log" 2>&1 || rc=$?
|
||||
if [ "$rc" = "0" ]; then ok "self-update run exits 0"; else bad "expected exit 0, got $rc"; fi
|
||||
if cmp -s ./update.sh "$UPDATE_SH"; then ok "running update.sh left byte-identical"; else bad "running update.sh was modified in place"; fi
|
||||
assert_no_line "./update.sh" '^(<<<<<<<|=======|>>>>>>>)' "no conflict markers written into the running update.sh"
|
||||
assert_path_exists "$SELFDEP/update.sh.dist" "target's update.sh staged as update.sh.dist"
|
||||
assert_file_contains "$SELFDEP/update.sh.dist" "THIS-IS-THE-NEW-UPDATE-SH" "update.sh.dist holds the target's version"
|
||||
assert_file_contains "$WORK/self.log" "update.sh.dist" "summary points the user at update.sh.dist"
|
||||
|
||||
# --- summary -----------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Result: $PASS passed, $FAIL failed ==="
|
||||
[ "$FAIL" = "0" ] || exit 1
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Validate the upgrade manifest (upgrades.json), which update.sh reads with jq.
|
||||
#
|
||||
# - json: upgrades.json is valid JSON
|
||||
# - keys: top-level keys are bare-semver versions (e.g. "0.7.0"), plus the
|
||||
# optional "_schema" documentation block
|
||||
# - schema: each version-keyed entry has only known fields, with valid types
|
||||
# (an unknown/misspelled key like "breakng" would silently disarm
|
||||
# its gate, so it is rejected here)
|
||||
# - gate: any non-null "gate" points at a script that exists in the repo
|
||||
#
|
||||
# The manifest is the source of truth for gating; the CHANGELOG is display only,
|
||||
# so this test deliberately does NOT cross-check the two. Requires jq (already a
|
||||
# runtime dependency of update.sh); no yq, no generation step.
|
||||
#
|
||||
# Usage:
|
||||
# sh tests/test-upgrades-manifest.sh # run from the docker/ directory
|
||||
#
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
DOCKER_DIR=$(dirname "$SCRIPT_DIR")
|
||||
cd "$DOCKER_DIR"
|
||||
|
||||
JSON=upgrades.json
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is required"; exit 1; }
|
||||
[ -f "$JSON" ] || { echo "ERROR: $JSON missing"; exit 1; }
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT INT TERM
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; }
|
||||
bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; }
|
||||
|
||||
echo ""
|
||||
echo "=== upgrades.json is valid JSON ==="
|
||||
if jq -e . "$JSON" >/dev/null 2>"$TMP/err"; then
|
||||
ok "upgrades.json parses"
|
||||
else
|
||||
bad "upgrades.json is not valid JSON: $(cat "$TMP/err" 2>/dev/null)"
|
||||
echo "=== Result: $PASS passed, $FAIL failed ==="; exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== top-level keys are versions (or _schema) ==="
|
||||
for k in $(jq -r 'keys[]' "$JSON"); do
|
||||
case "$k" in
|
||||
_schema) ok "doc block '_schema' present" ;;
|
||||
[0-9]*.[0-9]*) ok "version key $k" ;;
|
||||
*) bad "unexpected top-level key '$k' (want bare semver like 0.7.0)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== version-keyed entries have only known fields, with valid types ==="
|
||||
for k in $(jq -r 'keys[]' "$JSON"); do
|
||||
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
|
||||
errs=$(jq -r --arg k "$k" '.[$k] as $e
|
||||
| (($e | keys) - ["breaking", "gate", "migration_guide_url", "requires"]) as $unknown
|
||||
| [ (if ($e.breaking != null) and (($e.breaking|type) != "boolean") then "breaking must be bool" else empty end),
|
||||
(if ($e.gate != null) and (($e.gate|type) != "string") then "gate must be string|null" else empty end),
|
||||
(if ($e.migration_guide_url != null) and (($e.migration_guide_url|type) != "string") then "migration_guide_url must be string|null" else empty end),
|
||||
(if ($e.requires != null) and (($e.requires|type) != "array") then "requires must be array" else empty end),
|
||||
(if ($unknown | length) > 0 then "unknown field(s) (typo?): " + ($unknown | join(", ")) else empty end)
|
||||
] | join("; ")' "$JSON")
|
||||
if [ -z "$errs" ]; then ok "entry $k valid"; else bad "entry $k: $errs"; fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== gate scripts referenced by entries exist ==="
|
||||
checked=0
|
||||
for k in $(jq -r 'keys[]' "$JSON"); do
|
||||
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
|
||||
gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$JSON")
|
||||
[ -n "$gate" ] || continue
|
||||
checked=$((checked+1))
|
||||
if [ -f "$gate" ]; then
|
||||
ok "gate for $k exists: $gate"
|
||||
else
|
||||
bad "gate for $k missing: $gate"
|
||||
fi
|
||||
done
|
||||
[ "$checked" = 0 ] && echo " (no entries reference a gate script)"
|
||||
|
||||
echo ""
|
||||
echo "=== Result: $PASS passed, $FAIL failed ==="
|
||||
[ "$FAIL" = 0 ] || exit 1
|
||||
Reference in New Issue
Block a user