From 949f98b73aa6898adb29af6acd66b8d4babcec77 Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:00:25 +0100 Subject: [PATCH] add various tests for self-hosted --- docker/tests/docker-compose.rustfs.test.yml | 11 + docker/tests/docker-compose.s3.test.yml | 13 + docker/tests/test-auth-keys.sh | 369 ++++++++++++++++++++ docker/tests/test-container-logs.sh | 104 ++++++ docker/tests/test-s3-backend.sh | 353 +++++++++++++++++++ docker/tests/test-s3.sh | 292 ++++++++++++++++ docker/tests/test-self-hosted.sh | 351 +++++++++++++++++++ 7 files changed, 1493 insertions(+) create mode 100644 docker/tests/docker-compose.rustfs.test.yml create mode 100644 docker/tests/docker-compose.s3.test.yml create mode 100644 docker/tests/test-auth-keys.sh create mode 100644 docker/tests/test-container-logs.sh create mode 100644 docker/tests/test-s3-backend.sh create mode 100644 docker/tests/test-s3.sh create mode 100644 docker/tests/test-self-hosted.sh diff --git a/docker/tests/docker-compose.rustfs.test.yml b/docker/tests/docker-compose.rustfs.test.yml new file mode 100644 index 00000000000..2dce01db415 --- /dev/null +++ b/docker/tests/docker-compose.rustfs.test.yml @@ -0,0 +1,11 @@ +# Test overlay: 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" diff --git a/docker/tests/docker-compose.s3.test.yml b/docker/tests/docker-compose.s3.test.yml new file mode 100644 index 00000000000..4147c17f38b --- /dev/null +++ b/docker/tests/docker-compose.s3.test.yml @@ -0,0 +1,13 @@ +# Test overlay: 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" diff --git a/docker/tests/test-auth-keys.sh b/docker/tests/test-auth-keys.sh new file mode 100644 index 00000000000..03a90f979c8 --- /dev/null +++ b/docker/tests/test-auth-keys.sh @@ -0,0 +1,369 @@ +#!/bin/sh +# +# Test API key types and asymmetric auth against a running self-hosted instance. +# +# Usage: +# sh test-auth-keys.sh # Uses http://localhost:8000 +# sh test-auth-keys.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance +# - .env file with all keys configured +# - jq (for JSON parsing) +# - node >= 16 (for HS256 token minting test only) +# + +set -e + +BASE_URL="${1:-http://localhost:8000}" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +for cmd in jq node; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "Error: $cmd not found." + exit 1 + fi +done + +# Read keys from .env +JWT_SECRET=$(grep '^JWT_SECRET=' .env | cut -d= -f2-) +ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-) +SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-) +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-) + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name (HTTP $actual)" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +http_status() { + url="$1" + shift + curl -s -o /dev/null -w "%{http_code}" "$@" "$url" +} + +echo "" +echo "=== Testing against $BASE_URL ===" +echo "" + +# --------------------------------------------- +# 1. Route tests with API key types +# --------------------------------------------- + +echo "--- REST API (/rest/v1/) ---" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $ANON_KEY")" +check "Legacy SERVICE_ROLE_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SERVICE_ROLE_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" + check "New SECRET_KEY" "200" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_SECRET_KEY")" +else + echo " SKIP: Opaque keys not configured" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/rest/v1/")" +check "Invalid key -> 401" "401" \ + "$(http_status "$BASE_URL/rest/v1/" -H "apikey: invalid-key")" + +echo "" +echo "--- Auth (/auth/v1/settings) ---" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $ANON_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/auth/v1/settings")" + +echo "" +echo "--- Storage (/storage/v1/bucket) ---" +# Storage has no key-auth - passes through, Storage returns its own errors +check "No key -> not 401 (Storage handles auth)" "true" \ + "$([ "$(http_status "$BASE_URL/storage/v1/bucket")" != "401" ] && echo true || echo false)" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + # With opaque key, Kong translates to asymmetric JWT in Authorization + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" +fi + +echo "" +echo "--- Storage S3 (/storage/v1/s3/) ---" +# S3 uses AWS SigV4 auth (not apikey) - the request-transformer Lua expression +# passes the Authorization header through unchanged for non-sb_ values +check "S3 route accessible" "true" \ + "$([ "$(http_status "$BASE_URL/storage/v1/s3/")" != "502" ] && echo true || echo false)" + +echo "" +echo "--- GraphQL (/graphql/v1) ---" +check "Legacy ANON_KEY" "200" \ + "$(http_status "$BASE_URL/graphql/v1" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}')" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY" "200" \ + "$(http_status "$BASE_URL/graphql/v1" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}')" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/graphql/v1" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}')" + +echo "" +echo "--- Realtime REST (/realtime/v1/api/) ---" +# Realtime REST API - expect 200 or other non-401 response with valid key +check "Legacy ANON_KEY -> not 401" "true" \ + "$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "New PUBLISHABLE_KEY -> not 401" "true" \ + "$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" != "401" ] && echo true || echo false)" +fi + +check "No key -> 401" "401" \ + "$(http_status "$BASE_URL/realtime/v1/api/tenants")" + +echo "" +echo "--- supabase-js style requests (apikey + Authorization) ---" +# supabase-js sends both apikey header AND Authorization: Bearer +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "apikey + Authorization: Bearer sb_ (replace path)" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")" +fi + +check "Legacy apikey + Authorization: Bearer " "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $ANON_KEY")" + +echo "" +echo "--- Edge cases ---" +# Opaque key in Authorization only (no apikey header) - should be rejected by key-auth +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "sb_ in Authorization only (no apikey) -> 401" "401" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")" +fi + +echo "" +echo "--- JWKS endpoint ---" +check "JWKS public endpoint (no auth)" "200" \ + "$(http_status "$BASE_URL/auth/v1/.well-known/jwks.json")" + +# Verify JWKS content: should have EC key, should NOT have symmetric key +jwks_content=$(curl -s "$BASE_URL/auth/v1/.well-known/jwks.json") +jwks_has_ec=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "EC") then "true" else "false" end' 2>/dev/null) +jwks_has_oct=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "oct") then "true" else "false" end' 2>/dev/null) +check "JWKS contains EC public key" "true" "$jwks_has_ec" +check "JWKS does NOT contain symmetric key" "false" "$jwks_has_oct" + +#echo "" +#echo "--- OAuth metadata endpoint ---" +#check "well-known oauth (no auth)" "200" \ +# "$(http_status "$BASE_URL/.well-known/oauth-authorization-server")" + +echo "" +echo "--- Realtime WebSocket upgrade ---" +# Test that WebSocket upgrade request gets through (expect 101 or non-401) +# curl --max-time to prevent hanging on successful upgrade (101 keeps connection open) +ws_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \ + "$BASE_URL/realtime/v1/websocket?apikey=$ANON_KEY&vsn=1.0.0" \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000") +# 101 = upgrade success, 000 = timeout (connection stayed open = success) +check "WebSocket upgrade with legacy key -> not 401" "true" \ + "$([ "$ws_status" != "401" ] && echo true || echo false)" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + ws_status_new=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \ + "$BASE_URL/realtime/v1/websocket?apikey=$SUPABASE_PUBLISHABLE_KEY&vsn=1.0.0" \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000") + check "WebSocket upgrade with opaque key -> not 401" "true" \ + "$([ "$ws_status_new" != "401" ] && echo true || echo false)" +fi + +# --------------------------------------------- +# 2. User session JWT tests +# --------------------------------------------- + +echo "" +echo "--- User session JWT ---" + +# Create user via admin API (works regardless of email autoconfirm setting) +test_email="test-keys-$$@example.com" +test_password="test-password-123456" + +create_resp=$(curl -s "$BASE_URL/auth/v1/admin/users" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}") + +test_user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null) + +# Sign in to get session JWT +auth_response=$(curl -s "$BASE_URL/auth/v1/token?grant_type=password" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}") + +access_token=$(echo "$auth_response" | jq -r '.access_token // empty' 2>/dev/null) + +if [ -n "$access_token" ]; then + # Check the algorithm in the JWT header + jwt_alg=$(echo "$access_token" | cut -d. -f1 | \ + jq -Rr '@base64d | fromjson | .alg // empty' 2>/dev/null) + + if [ -n "$jwt_alg" ]; then + echo " INFO: User session JWT signed with: $jwt_alg" + if [ "$jwt_alg" = "ES256" ]; then + check "JWT uses ES256 (asymmetric)" "ES256" "$jwt_alg" + else + check "JWT uses HS256 (legacy)" "HS256" "$jwt_alg" + fi + fi + + # Use the session JWT with PostgREST + check "Session JWT with PostgREST" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + + # Use the session JWT with Storage + check "Session JWT with Storage" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + + # CRITICAL: Authenticated user + opaque key (most common supabase-js flow) + # supabase-js sends apikey: sb_publishable_xxx AND Authorization: Bearer + # The expression MUST keep the user JWT and NOT replace it with the anon asymmetric JWT + if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + echo "" + echo "--- Authenticated user + opaque key (critical path) ---" + check "Opaque apikey + user JWT -> PostgREST uses user JWT" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + check "Opaque apikey + user JWT -> Storage uses user JWT" "200" \ + "$(http_status "$BASE_URL/storage/v1/bucket" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + check "Opaque apikey + user JWT -> Auth uses user JWT" "200" \ + "$(http_status "$BASE_URL/auth/v1/user" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token")" + fi +else + check "Sign in test user" "true" "false" +fi + +# Clean up test user +if [ -n "$test_user_id" ]; then + curl -s -o /dev/null "$BASE_URL/auth/v1/admin/users/$test_user_id" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" +fi + +# --------------------------------------------- +# 3. HS256 backward compatibility +# --------------------------------------------- + +echo "" +echo "--- HS256 backward compatibility ---" + +# Mint a legacy HS256 JWT with role=anon (simulating a pre-migration token) +hs256_token=$(JWT_SECRET="$JWT_SECRET" node -e " +const crypto = require('crypto'); +const header = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url'); +const payload = Buffer.from(JSON.stringify({ + role:'anon',iss:'supabase', + iat:Math.floor(Date.now()/1000), + exp:Math.floor(Date.now()/1000)+3600 +})).toString('base64url'); +const sig = crypto.createHmac('sha256',process.env.JWT_SECRET) + .update(header+'.'+payload).digest('base64url'); +console.log(header+'.'+payload+'.'+sig); +" 2>/dev/null) + +if [ -n "$hs256_token" ]; then + check "HS256 token with PostgREST (backward compat)" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $hs256_token")" +else + echo " SKIP: Could not mint HS256 token (node required)" +fi + +# --------------------------------------------- +# 4. JWT_KEYS format validation +# --------------------------------------------- + +echo "" +echo "--- JWT_KEYS format ---" + +JWT_KEYS_VAL=$(grep '^JWT_KEYS=' .env | cut -d= -f2-) +if [ -n "$JWT_KEYS_VAL" ]; then + # Auth expects a JSON array, not a JWKS object + jwt_keys_is_array=$(echo "$JWT_KEYS_VAL" | jq -r 'if type == "array" then "true" else "false" end' 2>/dev/null) + check "JWT_KEYS is JSON array (not JWKS object)" "true" "$jwt_keys_is_array" + + jwt_keys_has_sign=$(echo "$JWT_KEYS_VAL" | jq -r 'if any(.[]; .key_ops and (.key_ops | index("sign"))) then "true" else "false" end' 2>/dev/null) + check "JWT_KEYS has a signing key (key_ops: sign)" "true" "$jwt_keys_has_sign" +else + echo " SKIP: JWT_KEYS not configured" +fi + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/docker/tests/test-container-logs.sh b/docker/tests/test-container-logs.sh new file mode 100644 index 00000000000..685ff2492ad --- /dev/null +++ b/docker/tests/test-container-logs.sh @@ -0,0 +1,104 @@ +#!/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 + +fail_msg() { + fail=$((fail + 1)) + echo " FAIL: $1" +} + +pass_msg() { + pass=$((pass + 1)) + echo " PASS: $1" +} + +# Check that a service's logs contain all expected patterns +check_logs() { + service="$1" + shift + + logs=$(docker compose logs "$service" 2>/dev/null) + if [ -z "$logs" ]; then + fail_msg "$service (no logs found)" + return + fi + + for pattern in "$@"; do + if ! echo "$logs" | grep -q -i -E "$pattern"; then + fail_msg "$service (missing: $pattern)" + return + fi + done + + pass_msg "$service" +} + +echo "" +echo "=== Checking service startup logs ===" +echo "" + +check_logs db \ + 'PostgreSQL init process complete; ready for start up.|Skipping initialization' + +check_logs auth \ + 'db worker started' + +check_logs kong \ + 'init.lua.*declarative config loaded' + +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 analytics \ + 'Access LogflareWeb.Endpoint at http://localhost:4000' \ + 'Initializing alerts scheduler' + +check_logs supavisor \ + 'Connected to Postgres database' \ + 'HEAD /api/health$' + +check_logs 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 " + echo "" + exit 1 +fi diff --git a/docker/tests/test-s3-backend.sh b/docker/tests/test-s3-backend.sh new file mode 100644 index 00000000000..537a07d2689 --- /dev/null +++ b/docker/tests/test-s3-backend.sh @@ -0,0 +1,353 @@ +#!/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 # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance with S3 backend + test overlay: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml \ +# -f ./tests/docker-compose.s3.test.yml up -d +# - .env file with MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, GLOBAL_S3_BUCKET +# - aws cli v2 +# - jq (for JSON parsing) +# + +set -e + +cleanup_files="" +trap 'rm -f $cleanup_files' EXIT + +BACKEND_URL="${1:-http://localhost:${S3_BACKEND_TEST_PORT:-9100}}" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +for cmd in aws jq; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "Error: $cmd not found." + exit 1 + fi +done + +# Read backend credentials from .env +BACKEND_ACCESS_KEY=$(grep '^MINIO_ROOT_USER=' .env | cut -d= -f2-) +BACKEND_SECRET_KEY=$(grep '^MINIO_ROOT_PASSWORD=' .env | cut -d= -f2-) +GLOBAL_S3_BUCKET=$(grep '^GLOBAL_S3_BUCKET=' .env | cut -d= -f2-) +REGION=$(grep '^REGION=' .env | cut -d= -f2-) +REGION="${REGION:-us-east-1}" + +if [ -z "$BACKEND_ACCESS_KEY" ] || [ -z "$BACKEND_SECRET_KEY" ]; then + echo "Error: MINIO_ROOT_USER or MINIO_ROOT_PASSWORD not set in .env" + exit 1 +fi + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +# Wrapper for aws commands against the backend directly +s3() { + AWS_ACCESS_KEY_ID="$BACKEND_ACCESS_KEY" \ + AWS_SECRET_ACCESS_KEY="$BACKEND_SECRET_KEY" \ + aws "$@" --endpoint-url "$BACKEND_URL" --region "$REGION" 2>&1 +} + +bucket_name="backend-test-$$" + +echo "" +echo "=== S3 backend test against $BACKEND_URL ===" +echo "" + +# --------------------------------------------- +# 1. ListBuckets (backend reachable) +# --------------------------------------------- + +echo "--- Connectivity ---" +list_output=$(s3 s3api list-buckets --output json) +list_ok=$(echo "$list_output" | jq -r 'if .Buckets then "true" else "false" end' 2>/dev/null) +check "Backend reachable (ListBuckets)" "true" "$list_ok" + +if [ "$list_ok" != "true" ]; then + echo " Cannot reach backend. Is the test overlay running?" + echo " Response: $list_output" + echo "" + echo "=== Results: $pass passed, $fail failed ===" + exit 1 +fi + +# --------------------------------------------- +# 2. Verify GLOBAL_S3_BUCKET exists +# --------------------------------------------- + +echo "" +echo "--- Storage bucket ---" +if [ -n "$GLOBAL_S3_BUCKET" ]; then + storage_bucket_exists=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$GLOBAL_S3_BUCKET" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) + check "GLOBAL_S3_BUCKET ($GLOBAL_S3_BUCKET) exists" "true" "$storage_bucket_exists" +else + echo " SKIP: GLOBAL_S3_BUCKET not set" +fi + +# --------------------------------------------- +# 3. CreateBucket +# --------------------------------------------- + +echo "" +echo "--- CreateBucket ---" +s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null 2>&1 + +# Verify create succeeded +create_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "CreateBucket" "true" "$create_found" + +if [ "$create_found" != "true" ]; then + echo " Cannot continue without a bucket. Aborting." + echo "" + echo "=== Results: $pass passed, $fail failed ===" + exit 1 +fi + +# Verify in ListBuckets (separate call) +bucket_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "Bucket visible in ListBuckets" "true" "$bucket_found" + +# --------------------------------------------- +# 4. PutObject +# --------------------------------------------- + +echo "" +echo "--- PutObject ---" +tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile" +echo "hello from backend test" > "$tmpfile" +put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/test-file.txt") +put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false") +check "PutObject" "true" "$put_ok" + +# --------------------------------------------- +# 5. ListObjectsV2 +# --------------------------------------------- + +echo "" +echo "--- ListObjectsV2 ---" +list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +object_found=$(echo "$list_objects" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "test-file.txt") then "true" else "false" end' 2>/dev/null) +check "Object found in ListObjectsV2" "true" "$object_found" + +# --------------------------------------------- +# 6. HeadObject +# --------------------------------------------- + +echo "" +echo "--- HeadObject ---" +head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "test-file.txt" --output json) +head_size=$(echo "$head_output" | jq -r '.ContentLength // 0' 2>/dev/null) +original_size=$(wc -c < "$tmpfile" | tr -d ' ') +check "HeadObject returns correct size" "$original_size" "$head_size" +rm -f "$tmpfile" + +# --------------------------------------------- +# 7. GetObject + content verify +# --------------------------------------------- + +echo "" +echo "--- GetObject ---" +download_file=$(mktemp); cleanup_files="$cleanup_files $download_file" +s3 s3 cp "s3://$bucket_name/test-file.txt" "$download_file" >/dev/null +downloaded_content=$(cat "$download_file") +check "GetObject content matches" "hello from backend test" "$downloaded_content" +rm -f "$download_file" + +# --------------------------------------------- +# 8. CopyObject +# --------------------------------------------- + +echo "" +echo "--- CopyObject ---" +copy_output=$(s3 s3 cp "s3://$bucket_name/test-file.txt" "s3://$bucket_name/test-copy.txt") +copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false") +check "CopyObject" "true" "$copy_ok" + +copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download" +s3 s3 cp "s3://$bucket_name/test-copy.txt" "$copy_download" >/dev/null +check "Copied object content matches" "hello from backend test" "$(cat "$copy_download")" +rm -f "$copy_download" + +# --------------------------------------------- +# 9. DeleteObject +# --------------------------------------------- + +echo "" +echo "--- DeleteObject ---" +s3 s3 rm "s3://$bucket_name/test-copy.txt" >/dev/null +list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +copy_gone=$(echo "$list_after_delete" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "test-copy.txt") then "false" else "true" end' 2>/dev/null) +check "Deleted object no longer listed" "true" "$copy_gone" + +# --------------------------------------------- +# 10. Multipart upload (7MB) +# --------------------------------------------- + +echo "" +echo "--- Multipart upload (7MB) ---" +large_file=$(mktemp); cleanup_files="$cleanup_files $large_file" +dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null +large_size=$(wc -c < "$large_file" | tr -d ' ') +large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin") +large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false") +check "Multipart upload (7MB)" "true" "$large_ok" + +large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json) +remote_size=$(echo "$large_head" | jq -r '.ContentLength // 0' 2>/dev/null) +check "Multipart size matches ($large_size bytes)" "$large_size" "$remote_size" + +large_download=$(mktemp); cleanup_files="$cleanup_files $large_download" +s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null +download_size=$(wc -c < "$large_download" | tr -d ' ') +check "Multipart download size matches" "$large_size" "$download_size" +rm -f "$large_file" "$large_download" + +# --------------------------------------------- +# 11. DeleteObjects (batch delete) +# --------------------------------------------- + +echo "" +echo "--- DeleteObjects (batch) ---" +batch_file=$(mktemp); cleanup_files="$cleanup_files $batch_file" +echo "batch-a" > "$batch_file" +s3 s3 cp "$batch_file" "s3://$bucket_name/batch-a.txt" >/dev/null +echo "batch-b" > "$batch_file" +s3 s3 cp "$batch_file" "s3://$bucket_name/batch-b.txt" >/dev/null +echo "batch-c" > "$batch_file" +s3 s3 cp "$batch_file" "s3://$bucket_name/batch-c.txt" >/dev/null +rm -f "$batch_file" +delete_objects_output=$(s3 s3api delete-objects --bucket "$bucket_name" \ + --delete '{"Objects":[{"Key":"batch-a.txt"},{"Key":"batch-b.txt"},{"Key":"batch-c.txt"}]}' \ + --output json) +deleted_count=$(echo "$delete_objects_output" | jq -r '.Deleted | length' 2>/dev/null) +check "DeleteObjects removed 3 objects" "3" "$deleted_count" + +# Verify all gone +batch_remaining=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --prefix "batch-" --output json | \ + jq -r '.KeyCount // 0' 2>/dev/null) +check "Batch-deleted objects gone" "0" "$batch_remaining" + +# --------------------------------------------- +# 12. Presigned URLs +# --------------------------------------------- + +echo "" +echo "--- Presigned URLs ---" +presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file" +echo "presigned content test" > "$presign_file" +s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null +rm -f "$presign_file" + +presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt") +presign_body=$(curl -s "$presigned_url") +check "Presigned URL returns correct content" "presigned content test" "$presign_body" + +# --------------------------------------------- +# 13. Conditional request (IfNoneMatch) +# --------------------------------------------- + +echo "" +echo "--- Conditional request (IfNoneMatch) ---" +cond_file=$(mktemp); cleanup_files="$cleanup_files $cond_file" +echo "conditional test" > "$cond_file" + +# First put should succeed (key doesn't exist) +s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \ + --body "$cond_file" --if-none-match '*' --output json >/dev/null 2>&1 +first_put=$? +check "IfNoneMatch put (new key) succeeds" "0" "$first_put" + +# 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" + +# --------------------------------------------- +# 14. Range request (partial GetObject) +# --------------------------------------------- + +echo "" +echo "--- Range request ---" +range_file=$(mktemp); cleanup_files="$cleanup_files $range_file" +echo "hello range test content" > "$range_file" +s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null +rm -f "$range_file" + +range_download=$(mktemp); cleanup_files="$cleanup_files $range_download" +s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \ + --range "bytes=0-4" "$range_download" --output json >/dev/null +range_content=$(cat "$range_download") +check "Range request returns partial content" "hello" "$range_content" +rm -f "$range_download" + +# --------------------------------------------- +# 15. Authentication +# --------------------------------------------- + +echo "" +echo "--- Authentication ---" +bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \ + AWS_SECRET_ACCESS_KEY="invalid-secret" \ + aws s3api list-buckets \ + --endpoint-url "$BACKEND_URL" \ + --region "$REGION" \ + --output json 2>&1 || true) +bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false") +check "Invalid credentials rejected" "true" "$bad_ok" + +# --------------------------------------------- +# 16. Cleanup +# --------------------------------------------- + +echo "" +echo "--- Cleanup ---" +s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null +s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null +bucket_gone=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end' 2>/dev/null) +check "Test bucket deleted" "true" "$bucket_gone" + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/docker/tests/test-s3.sh b/docker/tests/test-s3.sh new file mode 100644 index 00000000000..939547dd1c7 --- /dev/null +++ b/docker/tests/test-s3.sh @@ -0,0 +1,292 @@ +#!/bin/sh +# +# Test S3 protocol endpoint for self-hosted Supabase Storage. +# +# Verifies that the S3-compatible endpoint at /storage/v1/s3 works with +# standard S3 clients — the same way end users interact with it via +# aws cli, rclone, or other S3-compatible tools. +# +# Usage: +# sh test-s3.sh # Uses http://localhost:8000 +# sh test-s3.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance with S3 enabled: +# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +# - .env file with S3_PROTOCOL_ACCESS_KEY_ID, S3_PROTOCOL_ACCESS_KEY_SECRET, REGION +# - aws cli v2 (for S3 operations) +# - jq (for JSON parsing) +# + +set -e + +cleanup_files="" +trap 'rm -f $cleanup_files' EXIT + +BASE_URL="${1:-http://localhost:8000}" +S3_ENDPOINT="$BASE_URL/storage/v1/s3" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +for cmd in aws jq; do + if ! command -v $cmd >/dev/null 2>&1; then + echo "Error: $cmd not found." + exit 1 + fi +done + +# Read keys from .env +S3_ACCESS_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_ID=' .env | cut -d= -f2-) +S3_SECRET_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_SECRET=' .env | cut -d= -f2-) +REGION=$(grep '^REGION=' .env | cut -d= -f2-) + +if [ -z "$S3_ACCESS_KEY" ] || [ -z "$S3_SECRET_KEY" ]; then + echo "Error: S3_PROTOCOL_ACCESS_KEY_ID or S3_PROTOCOL_ACCESS_KEY_SECRET not set in .env" + exit 1 +fi + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +# Wrapper for aws s3/s3api commands with correct endpoint and credentials +s3() { + AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \ + AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \ + aws "$@" --endpoint-url "$S3_ENDPOINT" --region "$REGION" 2>&1 +} + +bucket_name="s3-test-$$" + +echo "" +echo "=== S3 protocol test against $BASE_URL ===" +echo "" + +# --------------------------------------------- +# 1. S3 ListBuckets +# --------------------------------------------- + +echo "--- S3 ListBuckets ---" +list_output=$(s3 s3api list-buckets --output json) +list_ok=$(echo "$list_output" | jq -r 'if .Buckets then "true" else "false" end' 2>/dev/null) +check "ListBuckets returns valid response" "true" "$list_ok" + +# --------------------------------------------- +# 2. S3 CreateBucket +# --------------------------------------------- + +echo "" +echo "--- S3 CreateBucket ---" +s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null 2>&1 + +# Verify create succeeded +create_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "CreateBucket" "true" "$create_found" + +if [ "$create_found" != "true" ]; then + echo " Cannot continue without a bucket. Aborting." + echo "" + echo "=== Results: $pass passed, $fail failed ===" + exit 1 +fi + +# Verify bucket appears in ListBuckets (separate call) +s3_bucket_found=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end' 2>/dev/null) +check "Bucket visible in ListBuckets" "true" "$s3_bucket_found" + +# --------------------------------------------- +# 3. S3 PutObject +# --------------------------------------------- + +echo "" +echo "--- S3 PutObject ---" +tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile" +echo "hello from s3 upload test" > "$tmpfile" +put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/s3-uploaded.txt") +put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false") +check "PutObject upload" "true" "$put_ok" + +# --------------------------------------------- +# 4. S3 ListObjectsV2 +# --------------------------------------------- + +echo "" +echo "--- S3 ListObjectsV2 ---" +list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +object_found=$(echo "$list_objects" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "s3-uploaded.txt") then "true" else "false" end' 2>/dev/null) +check "Object found in ListObjectsV2" "true" "$object_found" + +# --------------------------------------------- +# 5. S3 HeadObject +# --------------------------------------------- + +echo "" +echo "--- S3 HeadObject ---" +head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "s3-uploaded.txt" --output json) +head_size=$(echo "$head_output" | jq -r '.ContentLength // 0' 2>/dev/null) +original_size=$(wc -c < "$tmpfile" | tr -d ' ') +check "HeadObject returns correct size" "$original_size" "$head_size" +rm -f "$tmpfile" + +# --------------------------------------------- +# 6. S3 GetObject (download) + content verify +# --------------------------------------------- + +echo "" +echo "--- S3 GetObject ---" +download_file=$(mktemp); cleanup_files="$cleanup_files $download_file" +s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "$download_file" >/dev/null +downloaded_content=$(cat "$download_file") +check "GetObject content matches" "hello from s3 upload test" "$downloaded_content" +rm -f "$download_file" + +# --------------------------------------------- +# 7. S3 CopyObject (server-side copy) +# --------------------------------------------- + +echo "" +echo "--- S3 CopyObject ---" +copy_output=$(s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "s3://$bucket_name/s3-copied.txt") +copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false") +check "CopyObject" "true" "$copy_ok" + +# Verify copied content +copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download" +s3 s3 cp "s3://$bucket_name/s3-copied.txt" "$copy_download" >/dev/null +check "Copied object content matches" "hello from s3 upload test" "$(cat "$copy_download")" +rm -f "$copy_download" + +# --------------------------------------------- +# 8. S3 DeleteObject +# --------------------------------------------- + +echo "" +echo "--- S3 DeleteObject ---" +s3 s3 rm "s3://$bucket_name/s3-copied.txt" >/dev/null +# Verify object is gone +list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json) +copied_gone=$(echo "$list_after_delete" | \ + jq -r '[.Contents[]? | .Key] | if any(. == "s3-copied.txt") then "false" else "true" end' 2>/dev/null) +check "Deleted object no longer listed" "true" "$copied_gone" + +# --------------------------------------------- +# 9. Multipart upload (>5MB triggers multipart) +# --------------------------------------------- + +echo "" +echo "--- S3 multipart upload (7MB) ---" +large_file=$(mktemp); cleanup_files="$cleanup_files $large_file" +dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null +large_size=$(wc -c < "$large_file" | tr -d ' ') +large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin") +large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false") +check "Multipart upload (7MB)" "true" "$large_ok" + +# Verify size via HeadObject +large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json) +remote_size=$(echo "$large_head" | jq -r '.ContentLength // 0' 2>/dev/null) +check "Multipart upload size matches ($large_size bytes)" "$large_size" "$remote_size" + +# Download and verify size +large_download=$(mktemp); cleanup_files="$cleanup_files $large_download" +s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null +download_size=$(wc -c < "$large_download" | tr -d ' ') +check "Multipart download size matches" "$large_size" "$download_size" +rm -f "$large_file" "$large_download" + +# --------------------------------------------- +# 10. Range request (partial GetObject) +# --------------------------------------------- + +echo "" +echo "--- Range request ---" +range_file=$(mktemp); cleanup_files="$cleanup_files $range_file" +echo "hello range test content" > "$range_file" +s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null +rm -f "$range_file" + +range_download=$(mktemp); cleanup_files="$cleanup_files $range_download" +s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \ + --range "bytes=0-4" "$range_download" --output json >/dev/null +range_content=$(cat "$range_download") +check "Range request returns partial content" "hello" "$range_content" +rm -f "$range_download" + +# --------------------------------------------- +# 11. Presigned URLs +# --------------------------------------------- +# Storage supports S3 presigned URLs (query-parameter auth), but Kong's +# request-transformer adds an empty Authorization header when the Lua +# expression evaluates to nil. Storage sees typeof "" === "string" and +# enters parseAuthorizationHeader instead of parseQuerySignature. +# This test will pass once the Kong config is fixed. + +echo "" +echo "--- Presigned URLs ---" +presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file" +echo "presigned content test" > "$presign_file" +s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null +rm -f "$presign_file" + +presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt") +presign_body=$(curl -s "$presigned_url") +check "Presigned URL returns correct content" "presigned content test" "$presign_body" + +# --------------------------------------------- +# 12. Authentication +# --------------------------------------------- + +echo "" +echo "--- Authentication ---" +bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \ + AWS_SECRET_ACCESS_KEY="invalid-secret" \ + aws s3api list-buckets \ + --endpoint-url "$S3_ENDPOINT" \ + --region "$REGION" \ + --output json 2>&1 || true) +bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false") +check "Invalid credentials rejected" "true" "$bad_ok" + +# --------------------------------------------- +# 13. Cleanup +# --------------------------------------------- + +echo "" +echo "--- Cleanup ---" +s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null +s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null +# Verify bucket is gone +bucket_gone=$(s3 s3api list-buckets --output json | \ + jq -r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end' 2>/dev/null) +check "Bucket deleted via S3" "true" "$bucket_gone" + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/docker/tests/test-self-hosted.sh b/docker/tests/test-self-hosted.sh new file mode 100644 index 00000000000..87e3c5b64ca --- /dev/null +++ b/docker/tests/test-self-hosted.sh @@ -0,0 +1,351 @@ +#!/bin/sh +# +# Smoke test for self-hosted Supabase - verifies core functionality end-to-end. +# +# Usage: +# sh test-self-hosted.sh # Uses http://localhost:8000 +# sh test-self-hosted.sh # Custom URL +# +# Prerequisites: +# - Running self-hosted Supabase instance +# - .env file with keys configured +# - jq (for JSON parsing) +# + +set -e + +cleanup_files="" +trap 'rm -f $cleanup_files' EXIT + +BASE_URL="${1:-http://localhost:8000}" + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run from the project directory." + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "Error: jq not found. Install it: https://jqlang.github.io/jq/download/" + exit 1 +fi + +# Read keys from .env +ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-) +SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-) +DASHBOARD_USERNAME=$(grep '^DASHBOARD_USERNAME=' .env | cut -d= -f2-) +DASHBOARD_PASSWORD=$(grep '^DASHBOARD_PASSWORD=' .env | cut -d= -f2-) + +pass=0 +fail=0 + +check() { + test_name="$1" + expected="$2" + actual="$3" + + if [ "$actual" = "$expected" ]; then + echo " PASS: $test_name" + pass=$((pass + 1)) + else + echo " FAIL: $test_name (expected $expected, got $actual)" + fail=$((fail + 1)) + fi +} + +http_status() { + url="$1" + shift + curl -s -o /dev/null -w "%{http_code}" "$@" "$url" +} + +http_body() { + url="$1" + shift + curl -s "$@" "$url" +} + +echo "" +echo "=== Self-hosted smoke test against $BASE_URL ===" +echo "" + +# --------------------------------------------- +# 1. Container health (via docker compose) +# --------------------------------------------- + +echo "--- Container health ---" +if command -v docker >/dev/null 2>&1; then + container_status=$(docker compose ps --format json 2>/dev/null | jq -rs ' + [.[] | select(.State != "running" or (.Health != "" and .Health != "healthy"))] + | (length | tostring) + "|" + ([.[] | .Service + ": State=" + .State + " Health=" + (.Health // "none")] | join(", ")) + ' 2>/dev/null || echo "?|") + unhealthy="${container_status%%|*}" + container_issues="${container_status#*|}" + if [ "$unhealthy" = "0" ]; then + check "All containers healthy" "0" "$unhealthy" + elif [ "$unhealthy" = "?" ]; then + echo " SKIP: Could not check container health" + else + check "All containers healthy ($container_issues)" "0" "$unhealthy" + fi +else + echo " SKIP: docker not available" +fi + +# --------------------------------------------- +# 2. Studio dashboard +# --------------------------------------------- + +echo "" +echo "--- Studio dashboard ---" +# Studio may redirect (307/302) after auth - follow redirects +check "Studio accessible with basic auth" "200" \ + "$(http_status "$BASE_URL/" -L -u "$DASHBOARD_USERNAME:$DASHBOARD_PASSWORD")" +check "Studio rejects without auth" "401" \ + "$(http_status "$BASE_URL/")" + +# --------------------------------------------- +# 3. Auth: create user, sign in, get user, public signup, delete +# --------------------------------------------- + +echo "" +echo "--- Auth: user lifecycle ---" + +test_email="smoke-test-$$@example.com" +test_password="smoke-test-password-123456" + +# Create user via admin API (works regardless of email autoconfirm setting) +create_resp=$(http_body "$BASE_URL/auth/v1/admin/users" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}") + +user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null) + +if [ -n "$user_id" ]; then + check "Create user (admin)" "true" "true" + + # Sign in via public endpoint + signin_resp=$(http_body "$BASE_URL/auth/v1/token?grant_type=password" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}") + + access_token=$(echo "$signin_resp" | jq -r '.access_token // empty' 2>/dev/null) + + if [ -n "$access_token" ]; then + check "Sign in user" "true" "true" + + # Get user profile with session JWT + check "Get user profile" "200" \ + "$(http_status "$BASE_URL/auth/v1/user" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $access_token")" + else + check "Sign in user" "true" "false" + fi + + # Delete user + delete_status=$(http_status "$BASE_URL/auth/v1/admin/users/$user_id" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "Delete user (admin)" "200" "$delete_status" +else + check "Create user (admin)" "true" "false" +fi + +# Public signup (optional — depends on email autoconfirm setting) +signup_email="smoke-signup-$$@example.com" +signup_resp=$(http_body "$BASE_URL/auth/v1/signup" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$signup_email\",\"password\":\"$test_password\"}") + +signup_token=$(echo "$signup_resp" | jq -r '.access_token // empty' 2>/dev/null) +signup_user_id=$(echo "$signup_resp" | jq -r '.id // .user.id // empty' 2>/dev/null) + +if [ -n "$signup_token" ]; then + check "Public signup (autoconfirm on)" "true" "true" +else + echo " SKIP: Public signup (autoconfirm is off)" +fi + +# Clean up signup user if created +if [ -n "$signup_user_id" ]; then + http_status "$BASE_URL/auth/v1/admin/users/$signup_user_id" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1 +fi + +# --------------------------------------------- +# 4. PostgREST: query +# --------------------------------------------- + +echo "" +echo "--- PostgREST ---" +check "REST API query" "200" \ + "$(http_status "$BASE_URL/rest/v1/" \ + -H "apikey: $ANON_KEY")" + +# --------------------------------------------- +# 5. GraphQL +# --------------------------------------------- + +echo "" +echo "--- GraphQL ---" +gql_resp=$(http_body "$BASE_URL/graphql/v1" \ + -H "apikey: $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ __typename }"}') +gql_has_data=$(echo "$gql_resp" | jq -r 'if .data then "true" else "false" end' 2>/dev/null) +check "GraphQL introspection" "true" "$gql_has_data" + +# --------------------------------------------- +# 6. Storage: create bucket, upload >6MB file, download, cleanup +# --------------------------------------------- + +echo "" +echo "--- Storage: bucket + file lifecycle ---" + +bucket_name="smoke-test-$$" + +# Create bucket +create_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"id\":\"$bucket_name\",\"name\":\"$bucket_name\",\"public\":true}") +check "Create bucket" "200" "$create_bucket_status" + +if [ "$create_bucket_status" = "200" ]; then + # Generate a ~7MB file + tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile" + dd if=/dev/urandom of="$tmpfile" bs=1048576 count=7 2>/dev/null + + # Upload file + upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$tmpfile") + check "Upload 7MB file" "200" "$upload_status" + + # Download file and verify size + download_size=$(curl -s \ + "$BASE_URL/storage/v1/object/public/$bucket_name/test-large-file.bin" | wc -c | tr -d ' ') + original_size=$(wc -c < "$tmpfile" | tr -d ' ') + check "Download file (size matches)" "true" \ + "$([ "$download_size" = "$original_size" ] && echo true || echo false)" + + rm -f "$tmpfile" + + # Signed URL: upload a small file, create signed URL, fetch without auth + sign_upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: text/plain" \ + --data-binary "signed url test content") + check "Upload file for signing" "200" "$sign_upload_status" + + if [ "$sign_upload_status" = "200" ]; then + sign_resp=$(http_body "$BASE_URL/storage/v1/object/sign/$bucket_name/sign-test.txt" \ + -X POST \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d '{"expiresIn": 600}') + signed_path=$(echo "$sign_resp" | jq -r '.signedURL // empty' 2>/dev/null) + + if [ -n "$signed_path" ]; then + check "Create signed URL" "true" "true" + # Fetch signed URL without any auth headers (goes through Kong) + signed_content=$(curl -s "$BASE_URL/storage/v1$signed_path") + check "Fetch signed URL (no auth)" "signed url test content" "$signed_content" + else + check "Create signed URL" "true" "false" + fi + fi + + # Delete file + delete_file_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "Delete file" "200" "$delete_file_status" + + # Delete signed test file + http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1 + + # Delete bucket + delete_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket/$bucket_name" \ + -X DELETE \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY") + check "Delete bucket" "200" "$delete_bucket_status" +fi + +# --------------------------------------------- +# 7. Edge Functions +# --------------------------------------------- + +echo "" +echo "--- Edge Functions ---" +fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \ + -X POST \ + -H "Authorization: Bearer $ANON_KEY" \ + -H "Content-Type: application/json" \ + -d '{}') +check "Call hello function" '"Hello from Edge Functions!"' "$fn_resp" + +# --------------------------------------------- +# 8. pg-meta (Studio backend) +# --------------------------------------------- + +echo "" +echo "--- pg-meta ---" +check "pg-meta with service_role key" "200" \ + "$(http_status "$BASE_URL/pg/schemas" \ + -H "apikey: $SERVICE_ROLE_KEY")" +check "pg-meta rejects anon key" "403" \ + "$(http_status "$BASE_URL/pg/schemas" \ + -H "apikey: $ANON_KEY")" +check "pg-meta rejects no key" "401" \ + "$(http_status "$BASE_URL/pg/schemas")" + +echo "" +echo "--- MCP (blocked by default) ---" +check "/api/mcp blocked" "403" \ + "$(http_status "$BASE_URL/api/mcp")" +check "/mcp blocked" "403" \ + "$(http_status "$BASE_URL/mcp")" + +# --------------------------------------------- +# 9. Realtime +# --------------------------------------------- + +echo "" +echo "--- Realtime ---" +check "Realtime health" "true" \ + "$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" \ + -H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)" + +# --------------------------------------------- +# Summary +# --------------------------------------------- + +echo "" +echo "=== Results: $pass passed, $fail failed ===" +echo "" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi