#!/bin/bash

# E2E: Storage access is enforced by Postgres RLS, not by app-side filtering.
#
# Migration 036 ships RLS enabled with zero default policies (matching
# Supabase). This script installs an owner-only starter set via psql so
# section 1 has something to verify against — it's also the recipe a
# project would use to re-enable the pre-RLS owner-only behavior.
#
# Sections:
#   1. Owner-only RLS — Alice/Bob can't see each other's files via list,
#      get, or delete. Admin sees everything.
#   2. RLS override — drop owner SELECT policy, install a permissive one
#      ("public read inside this bucket"), verify both users now see
#      everything WITHOUT the storage service changing.
#   3. Path-based RLS — use storage.foldername(key)[1] = sub, verify
#      only files inside `<user_id>/...` are visible.
#   4. Third-party-auth-shaped JWT (text sub) flows the same path as
#      native UUID JWTs. This is what makes Better Auth / Clerk / Auth0 /
#      WorkOS / Stytch / Kinde work.
#      (Skipped if JWT_SECRET is not retrievable.)
#
# psql is required to install/teardown policies. CI installs
# postgresql-client into the backend container before running.

SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPT_DIR/../test-config.sh"

API="${TEST_API_BASE:-http://localhost:7130/api}"
TS=$(date +%s)
PASS="testpass123"

ALICE_EMAIL="alice-rls-$TS@example.com"
BOB_EMAIL="bob-rls-$TS@example.com"
ANON_KEY=$(get_anon_key)

# === helpers ============================================================

# Pull an admin API key. Order:
#   1. TEST_API_KEY / ACCESS_API_KEY env (CI sets one of these)
#   2. Admin login → GET /api/metadata/api-key (works against any deployment)
#   3. Local-dev fallback: scrape ik_… from a known container's logs
API_KEY="${TEST_API_KEY:-${ACCESS_API_KEY:-}}"
if [ -z "$API_KEY" ]; then
  # Use the same admin login + /metadata/api-key fallback that
  # test-public-bucket.sh uses (proven to work in CI). grep+cut over
  # python3 to avoid an extra runtime dependency on the parser.
  ADMIN_TOKEN=$(get_admin_token)
  if [ -n "$ADMIN_TOKEN" ]; then
    API_KEY_RESPONSE=$(curl -sS "$API/metadata/api-key" -H "Authorization: Bearer $ADMIN_TOKEN")
    API_KEY=$(echo "$API_KEY_RESPONSE" | grep -o '"apiKey":"[^"]*' | cut -d'"' -f4)
  fi
fi
if [ -z "$API_KEY" ] && command -v docker >/dev/null 2>&1; then
  API_KEY=$(docker logs ba-sdk-test-insforge-1 2>&1 | grep -oE 'ik_[a-f0-9]+' | tail -1 || true)
fi
if [ -z "$API_KEY" ]; then
  print_fail "Could not get API key (set TEST_API_KEY or ACCESS_API_KEY)"
  print_info "  TEST_API_BASE=$TEST_API_BASE"
  print_info "  TEST_ADMIN_USERNAME=$TEST_ADMIN_USERNAME"
  print_info "  ADMIN_TOKEN length=${#ADMIN_TOKEN}"
  print_info "  /metadata/api-key response (first 200 chars): ${API_KEY_RESPONSE:0:200}"
  exit 1
fi
# Export so the test-config.sh cleanup trap can delete buckets.
export ACCESS_API_KEY="$API_KEY"

# psql is required: migration 036 ships RLS enabled with zero default
# policies, so the test installs its own owner-only set up front.
if ! command -v psql >/dev/null 2>&1 || [ -z "$DATABASE_URL" ]; then
  print_fail "psql + DATABASE_URL required (migration 036 has no default policies)"
  exit 1
fi

PATH_BUCKET="rls-path-$TS"

# Tear down all test-installed policies on exit so a shared DB ends in
# the same state the migration left it: RLS enabled, zero policies.
cleanup_storage_policies() {
  psql "$DATABASE_URL" >/dev/null 2>&1 <<'SQL' || true
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS storage_objects_public_read_test ON storage.objects;
DROP POLICY IF EXISTS storage_objects_path_select ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_select ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_insert ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_update ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_delete ON storage.objects;
SQL
}
trap cleanup_storage_policies EXIT

assert_count() {
  local label="$1" expected="$2" actual="$3"
  if [ "$expected" = "$actual" ]; then
    print_success "$label = $actual"
  else
    print_fail "$label expected $expected got $actual"
  fi
}

list_count() {
  local jwt="$1" bucket="$2"
  curl -sS "$API/storage/buckets/$bucket/objects" -H "Authorization: Bearer $jwt" \
    | grep -o '"total":[0-9]*' | head -1 | cut -d: -f2
}

upload() {
  local jwt="$1" bucket="$2" key="$3" content="$4"
  echo "$content" > "/tmp/_rls_$TS.txt"
  # Split decl from assignment so curl's exit status isn't masked by `local`.
  local code rc
  code=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \
    "$API/storage/buckets/$bucket/objects/$key" \
    -H "Authorization: Bearer $jwt" -F "file=@/tmp/_rls_$TS.txt")
  rc=$?
  rm -f "/tmp/_rls_$TS.txt"
  [ $rc -ne 0 ] && return $rc
  echo "$code"
}

# === setup ==============================================================

BUCKET="rls-test-$TS"
register_test_bucket "$BUCKET"

curl -sS -X POST "$API/storage/buckets" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d "{\"bucketName\":\"$BUCKET\",\"isPublic\":false}" > /dev/null
print_success "Bucket created: $BUCKET"

curl -sS -X POST "$API/auth/users" -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ANON_KEY" \
  -d "{\"email\":\"$ALICE_EMAIL\",\"password\":\"$PASS\",\"name\":\"Alice\"}" > /dev/null
curl -sS -X POST "$API/auth/users" -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ANON_KEY" \
  -d "{\"email\":\"$BOB_EMAIL\",\"password\":\"$PASS\",\"name\":\"Bob\"}" > /dev/null
register_test_user "$ALICE_EMAIL"
register_test_user "$BOB_EMAIL"

login_token() {
  curl -sS -X POST "$API/auth/sessions" -H "Content-Type: application/json" \
    -d "{\"email\":\"$1\",\"password\":\"$PASS\"}" \
    | grep -o '"accessToken":"[^"]*' | cut -d'"' -f4
}
ALICE_JWT=$(login_token "$ALICE_EMAIL")
BOB_JWT=$(login_token "$BOB_EMAIL")

# Pull the user IDs from the JWT (sub claim) for the path-based test.
# JWT uses base64url (no padding) — decode in node since python3 isn't
# guaranteed to be installed in the backend container.
jwt_sub() {
  node -e '
    const seg = process.argv[1].split(".")[1];
    const padded = seg + "=".repeat((4 - seg.length % 4) % 4);
    const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
    process.stdout.write(JSON.parse(json).sub);
  ' "$1"
}
ALICE_ID=$(jwt_sub "$ALICE_JWT")
BOB_ID=$(jwt_sub "$BOB_JWT")

[ -z "$ALICE_JWT" ] || [ -z "$BOB_JWT" ] && { print_fail "Login failed"; exit 1; }
print_success "Two users logged in (alice=$ALICE_ID, bob=$BOB_ID)"

# === 1. owner-only RLS =================================================

print_blue "
1. Owner-only RLS (starter policy set)"

# Install the owner-only starter set. Idempotent so the test runs the
# same way against a fresh CI DB (migration shipped no defaults) or an
# existing-project DB (migration installed the same set already).
psql "$DATABASE_URL" >/dev/null <<'SQL'
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS storage_objects_owner_select ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_insert ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_update ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_delete ON storage.objects;
CREATE POLICY storage_objects_owner_select ON storage.objects
  FOR SELECT TO authenticated
  USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
CREATE POLICY storage_objects_owner_insert ON storage.objects
  FOR INSERT TO authenticated
  WITH CHECK (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
CREATE POLICY storage_objects_owner_update ON storage.objects
  FOR UPDATE TO authenticated
  USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'))
  WITH CHECK (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
CREATE POLICY storage_objects_owner_delete ON storage.objects
  FOR DELETE TO authenticated
  USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
SQL

assert_count "Alice upload"  "201" "$(upload "$ALICE_JWT" "$BUCKET" "a.txt" alice)"
assert_count "Bob upload"    "201" "$(upload "$BOB_JWT"   "$BUCKET" "b.txt" bob)"

assert_count "Alice list"  "1" "$(list_count "$ALICE_JWT" "$BUCKET")"
assert_count "Bob list"    "1" "$(list_count "$BOB_JWT"   "$BUCKET")"
assert_count "Admin list"  "2" "$(list_count "$API_KEY"   "$BUCKET")"

bob_get_alice=$(curl -sS -o /dev/null -w "%{http_code}" \
  "$API/storage/buckets/$BUCKET/objects/a.txt" -H "Authorization: Bearer $BOB_JWT")
assert_count "Bob GET alice's file (RLS hides → 404)" "404" "$bob_get_alice"

bob_del_alice=$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE \
  "$API/storage/buckets/$BUCKET/objects/a.txt" -H "Authorization: Bearer $BOB_JWT")
assert_count "Bob DELETE alice's file (RLS blocks → 404)" "404" "$bob_del_alice"

assert_count "Alice's file survived" "1" "$(list_count "$ALICE_JWT" "$BUCKET")"

# Anon GET on a private bucket → 401 from conditionalAuth fast path.
anon_private=$(curl -sS -o /dev/null -w "%{http_code}" \
  "$API/storage/buckets/$BUCKET/objects/a.txt")
assert_count "Anon GET private bucket → 401" "401" "$anon_private"

# Hidden-key collision regression. Both users upload "shared.txt"; the
# second upload must auto-rename via dedup (admin-pool scan) instead of
# silently overwriting Alice's blob in the provider before failing UNIQUE.
upload "$ALICE_JWT" "$BUCKET" "shared.txt" "ALICE-shared-content" >/dev/null
bob_shared_resp=$(curl -sS -X PUT "$API/storage/buckets/$BUCKET/objects/shared.txt" \
  -H "Authorization: Bearer $BOB_JWT" -F "file=@/dev/stdin;filename=shared.txt" <<< "BOB-shared-content")
bob_shared_key=$(echo "$bob_shared_resp" | grep -oE '"key":"[^"]*' | head -1 | cut -d'"' -f4)
assert_count "Bob's shared.txt auto-renamed (no silent overwrite)" "shared (1).txt" "$bob_shared_key"
alice_shared_dl=$(curl -sSL "$API/storage/buckets/$BUCKET/objects/shared.txt" \
  -H "Authorization: Bearer $ALICE_JWT")
assert_count "Alice's shared.txt blob untouched" "ALICE-shared-content" "$alice_shared_dl"

# === 2. override SELECT policy → public-read bucket ====================

print_blue "
2. Override owner SELECT policy → public-read"

psql "$DATABASE_URL" >/dev/null <<SQL
DROP POLICY IF EXISTS storage_objects_owner_select ON storage.objects;
CREATE POLICY storage_objects_public_read_test ON storage.objects
  FOR SELECT TO authenticated
  USING (bucket = '$BUCKET');
SQL

# Both users see all four files now (a.txt, b.txt, shared.txt, shared (1).txt).
assert_count "Alice list (after override)" "4" "$(list_count "$ALICE_JWT" "$BUCKET")"
assert_count "Bob list (after override)"   "4" "$(list_count "$BOB_JWT"   "$BUCKET")"

# But INSERT/DELETE policies are unchanged — Bob still can't delete Alice's file
bob_del_alice2=$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE \
  "$API/storage/buckets/$BUCKET/objects/a.txt" -H "Authorization: Bearer $BOB_JWT")
assert_count "Bob DELETE alice's file (DELETE policy unchanged)" "404" "$bob_del_alice2"

# Cross-policy attack regression check. Bob already failed the DELETE above
# while having visibility — the critical follow-up is that Alice's blob is
# still on disk. If deleteObject ever regresses to "check visibility, then
# delete blob, then DB-DELETE", this read would 404. Follow redirects so
# we get the actual blob fetch status (200 local, S3-presigned-then-200).
alice_dl=$(curl -sSL -o /dev/null -w "%{http_code}" \
  "$API/storage/buckets/$BUCKET/objects/a.txt" -H "Authorization: Bearer $ALICE_JWT")
assert_count "Alice can still download her file (blob untouched)" "200" "$alice_dl"

# Restore the owner SELECT policy for clean teardown
psql "$DATABASE_URL" >/dev/null <<'SQL'
DROP POLICY IF EXISTS storage_objects_public_read_test ON storage.objects;
CREATE POLICY storage_objects_owner_select ON storage.objects
  FOR SELECT TO authenticated
  USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
SQL
print_success "Owner SELECT policy restored"

# === 3. path-based RLS using storage.foldername =========================

print_blue "
3. Path-based RLS (storage.foldername)"

register_test_bucket "$PATH_BUCKET"
curl -sS -X POST "$API/storage/buckets" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d "{\"bucketName\":\"$PATH_BUCKET\",\"isPublic\":false}" > /dev/null

# Each user uploads at <user_id>/note.txt — column-based RLS still applies, so
# this works.
assert_count "Alice upload alice/note" "201" \
  "$(upload "$ALICE_JWT" "$PATH_BUCKET" "${ALICE_ID}/note.txt" alice)"
assert_count "Bob upload bob/note"     "201" \
  "$(upload "$BOB_JWT"   "$PATH_BUCKET" "${BOB_ID}/note.txt" bob)"

# Scoped to $PATH_BUCKET so concurrent runs on other buckets aren't affected.
psql "$DATABASE_URL" >/dev/null <<SQL
DROP POLICY IF EXISTS storage_objects_owner_select ON storage.objects;
CREATE POLICY storage_objects_path_select ON storage.objects
  FOR SELECT TO authenticated
  USING (
    bucket = '$PATH_BUCKET'
    AND (storage.foldername(key))[1] = (SELECT auth.jwt() ->> 'sub')
  );
SQL

assert_count "Alice list (path policy)" "1" "$(list_count "$ALICE_JWT" "$PATH_BUCKET")"
assert_count "Bob list (path policy)"   "1" "$(list_count "$BOB_JWT"   "$PATH_BUCKET")"

# Restore owner SELECT so INSERT…RETURNING against $BUCKET in section 4 sees rows.
psql "$DATABASE_URL" >/dev/null <<'SQL'
DROP POLICY IF EXISTS storage_objects_path_select ON storage.objects;
CREATE POLICY storage_objects_owner_select ON storage.objects
  FOR SELECT TO authenticated
  USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
SQL

# === 4. third-party-auth-shaped JWT (text sub) =========================

print_blue "
4. Third-party-auth-shaped sub (e.g. Better Auth)"

# Forge a BA-shaped JWT signed with the project's JWT_SECRET so we don't
# need a running BA app to prove the storage path accepts text subs.
# Order: env var (CI), then psql lookup (locally encrypted secret).
JWT_SECRET_FOR_TEST="${JWT_SECRET:-${INSFORGE_JWT_SECRET:-}}"
if [ -z "$JWT_SECRET_FOR_TEST" ]; then
  JWT_SECRET_FOR_TEST=$(psql "$DATABASE_URL" -t -A -c \
    "SELECT system.decrypt_secret(value_ciphertext) FROM system.secrets WHERE key='JWT_SECRET' LIMIT 1;" 2>/dev/null || true)
fi

if [ -n "$JWT_SECRET_FOR_TEST" ] && command -v node >/dev/null 2>&1; then
  # Sign HS256 with Node's built-in crypto module — no jsonwebtoken dependency.
  BA_SUB="ZVP5j6raUC9cuBIWzDGjdNdelMFjWNc5"
  BA_JWT=$(BA_SUB="$BA_SUB" JWT_SECRET="$JWT_SECRET_FOR_TEST" node -e '
    const c = require("crypto");
    const b64u = (b) => Buffer.from(b).toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");
    const now = Math.floor(Date.now() / 1000);
    const header = b64u(JSON.stringify({ alg: "HS256", typ: "JWT" }));
    const payload = b64u(JSON.stringify({
      sub: process.env.BA_SUB,
      role: "authenticated",
      aud: "insforge-api",
      email: "ba@example.com",
      iat: now, exp: now + 300,
    }));
    const sig = b64u(c.createHmac("sha256", process.env.JWT_SECRET).update(header + "." + payload).digest());
    console.log(header + "." + payload + "." + sig);
  ' 2>/dev/null || true)

  if [ -n "$BA_JWT" ]; then
    assert_count "BA-shaped upload" "201" \
      "$(upload "$BA_JWT" "$BUCKET" "ba-note-$TS.txt" "ba content")"
    # BA user only sees their own file (column-based RLS still works for text sub)
    ba_count=$(list_count "$BA_JWT" "$BUCKET")
    assert_count "BA-shaped list (1 own file)" "1" "$ba_count"
  else
    print_info "Skipped: failed to forge test JWT (node crypto unavailable?)"
  fi
else
  print_info "Skipped: JWT_SECRET not retrievable from this environment"
fi

# === cleanup ============================================================

print_blue "
Cleanup"
psql "$DATABASE_URL" >/dev/null <<'SQL'
DELETE FROM storage.objects WHERE bucket LIKE 'rls-%';
DELETE FROM storage.buckets WHERE name LIKE 'rls-%';
SQL
print_success "Buckets removed (psql)"
# The trap from test-config.sh handles bucket/user cleanup via the API too.
# The cleanup_storage_policies trap drops every test-installed policy,
# leaving the table in the same RLS-enabled-no-policies state as the migration.

echo
echo "Storage RLS e2e: complete"
