10ba4f0a0c
Rescope OOBE claims with permitted ALTER operations and make deployment smoke validation require every migration through V28.
609 lines
20 KiB
Bash
Executable File
609 lines
20 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
COMPOSE_FILE="$ROOT_DIR/compose.smoke.yaml"
|
||
SMOKE_DIR="$ROOT_DIR/deploy/smoke"
|
||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/osg-account-smoke.XXXXXX")"
|
||
SECRET_DIR="$WORK_DIR/secrets"
|
||
DIAGNOSTICS_FILE="$WORK_DIR/diagnostics.log"
|
||
COMPOSE_ENV="$SECRET_DIR/compose.env"
|
||
RUNTIME_ENV="$SECRET_DIR/runtime.env"
|
||
ADMIN_ENV="$SECRET_DIR/admin.env"
|
||
ADMIN_GENERATED_ENV="$SECRET_DIR/admin.generated.env"
|
||
ADMIN_HANDOFF="$SECRET_DIR/admin-handoff.txt"
|
||
SUPER_COOKIE_JAR="$SECRET_DIR/super.cookies"
|
||
ANALYST_COOKIE_JAR="$SECRET_DIR/analyst.cookies"
|
||
RESPONSE_BODY="$WORK_DIR/response.json"
|
||
ACTUAL_PRIVILEGES="$WORK_DIR/runtime-privileges.tsv"
|
||
RUN_ID="$(date -u +%Y%m%d%H%M%S)-$$"
|
||
PROJECT_NAME="osg-account-smoke-$RUN_ID"
|
||
SMOKE_ACCOUNT_ID="10000000-0000-0000-0000-000000000001"
|
||
FAILURES=0
|
||
|
||
mkdir -m 700 "$SECRET_DIR"
|
||
touch "$ADMIN_ENV"
|
||
chmod 600 "$ADMIN_ENV"
|
||
|
||
log() {
|
||
printf '[smoke] %s\n' "$*"
|
||
}
|
||
|
||
fail() {
|
||
printf '[smoke] FAIL: %s\n' "$*" >&2
|
||
return 1
|
||
}
|
||
|
||
require_command() {
|
||
command -v "$1" >/dev/null 2>&1 || {
|
||
printf '[smoke] missing required command: %s\n' "$1" >&2
|
||
exit 1
|
||
}
|
||
}
|
||
|
||
random_hex() {
|
||
openssl rand -hex "$1"
|
||
}
|
||
|
||
random_base64_key() {
|
||
openssl rand -base64 32 | tr -d '\n'
|
||
}
|
||
|
||
free_loopback_port() {
|
||
python3 - <<'PY'
|
||
import socket
|
||
|
||
with socket.socket() as sock:
|
||
sock.bind(("127.0.0.1", 0))
|
||
print(sock.getsockname()[1])
|
||
PY
|
||
}
|
||
|
||
compose() {
|
||
docker compose \
|
||
--project-name "$PROJECT_NAME" \
|
||
--env-file "$COMPOSE_ENV" \
|
||
--file "$COMPOSE_FILE" \
|
||
"$@"
|
||
}
|
||
|
||
mysql_root() {
|
||
compose exec -T \
|
||
-e "MYSQL_PWD=$MYSQL_ROOT_PASSWORD" \
|
||
mysql mysql --protocol=tcp --host=127.0.0.1 --user=root "$@"
|
||
}
|
||
|
||
mysql_runtime() {
|
||
compose exec -T \
|
||
-e "MYSQL_PWD=$MYSQL_RUNTIME_PASSWORD" \
|
||
mysql mysql --protocol=tcp --host=127.0.0.1 --user=osg_smoke_runtime "$@"
|
||
}
|
||
|
||
capture_diagnostics() {
|
||
{
|
||
printf '%s\n' '=== compose ps ==='
|
||
compose --profile setup ps --all || true
|
||
printf '%s\n' '=== mysql/app logs (last 200 lines) ==='
|
||
compose --profile setup logs --no-color --tail 200 \
|
||
mysql schema-migrator account-server || true
|
||
} >"$DIAGNOSTICS_FILE" 2>&1
|
||
chmod 600 "$DIAGNOSTICS_FILE"
|
||
}
|
||
|
||
cleanup() {
|
||
local status=$?
|
||
trap - EXIT INT TERM
|
||
if ((status != 0)); then
|
||
capture_diagnostics
|
||
fi
|
||
compose --profile setup down --volumes --remove-orphans --rmi local >/dev/null 2>&1 || true
|
||
rm -f \
|
||
"$COMPOSE_ENV" \
|
||
"$RUNTIME_ENV" \
|
||
"$ADMIN_ENV" \
|
||
"$ADMIN_GENERATED_ENV" \
|
||
"$ADMIN_HANDOFF" \
|
||
"$SUPER_COOKIE_JAR" \
|
||
"$ANALYST_COOKIE_JAR" \
|
||
"$RESPONSE_BODY" \
|
||
"$ACTUAL_PRIVILEGES" \
|
||
"$WORK_DIR/denied.err"
|
||
rmdir "$SECRET_DIR" 2>/dev/null || true
|
||
if ((status == 0)); then
|
||
rm -f "$DIAGNOSTICS_FILE"
|
||
rmdir "$WORK_DIR" 2>/dev/null || true
|
||
log "PASS: containers, volume, image, and temporary credentials removed"
|
||
else
|
||
printf '[smoke] non-sensitive diagnostics kept at %s\n' "$DIAGNOSTICS_FILE" >&2
|
||
fi
|
||
exit "$status"
|
||
}
|
||
trap cleanup EXIT INT TERM
|
||
|
||
wait_for_service_http() {
|
||
local service=$1
|
||
local url=$2
|
||
local attempts=${3:-90}
|
||
local attempt
|
||
for ((attempt = 1; attempt <= attempts; attempt++)); do
|
||
if compose exec -T "$service" wget -q -O /dev/null "$url" 2>/dev/null; then
|
||
return 0
|
||
fi
|
||
if [[ "$(compose ps --all --format json "$service" 2>/dev/null || true)" == *'"State":"exited"'* ]]; then
|
||
return 1
|
||
fi
|
||
sleep 2
|
||
done
|
||
return 1
|
||
}
|
||
|
||
wait_for_host_http() {
|
||
local path=$1
|
||
local attempts=${2:-30}
|
||
local attempt status
|
||
for ((attempt = 1; attempt <= attempts; attempt++)); do
|
||
status="$(curl --silent --output /dev/null --write-out '%{http_code}' \
|
||
"http://127.0.0.1:$APP_PORT$path" 2>/dev/null || true)"
|
||
if [[ "$status" == "200" ]]; then
|
||
return 0
|
||
fi
|
||
sleep 1
|
||
done
|
||
return 1
|
||
}
|
||
|
||
http_request() {
|
||
local expected=$1
|
||
local method=$2
|
||
local path=$3
|
||
local cookie_jar=$4
|
||
local body=$5
|
||
shift 5
|
||
local -a command=(
|
||
curl --silent --show-error
|
||
--output "$RESPONSE_BODY"
|
||
--write-out '%{http_code}'
|
||
--request "$method"
|
||
"http://127.0.0.1:$APP_PORT$path"
|
||
)
|
||
local header
|
||
if [[ "$cookie_jar" != "-" ]]; then
|
||
command+=(--cookie "$cookie_jar" --cookie-jar "$cookie_jar")
|
||
fi
|
||
if [[ -n "$body" ]]; then
|
||
command+=(--header 'Content-Type: application/json' --data "$body")
|
||
fi
|
||
for header in "$@"; do
|
||
command+=(--header "$header")
|
||
done
|
||
|
||
local status
|
||
status="$("${command[@]}")"
|
||
[[ "$status" == "$expected" ]] || fail "$method $path returned $status, expected $expected"
|
||
}
|
||
|
||
json_value() {
|
||
local file=$1
|
||
shift
|
||
python3 - "$file" "$@" <<'PY'
|
||
import json
|
||
import sys
|
||
|
||
value = json.load(open(sys.argv[1], encoding="utf-8"))
|
||
for key in sys.argv[2:]:
|
||
if key.isdigit():
|
||
value = value[int(key)]
|
||
else:
|
||
value = value[key]
|
||
if value is None:
|
||
print("")
|
||
elif isinstance(value, bool):
|
||
print(str(value).lower())
|
||
else:
|
||
print(value)
|
||
PY
|
||
}
|
||
|
||
totp_code() {
|
||
local secret=$1
|
||
python3 - "$secret" <<'PY'
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import struct
|
||
import sys
|
||
import time
|
||
|
||
secret = base64.b32decode(sys.argv[1], casefold=True)
|
||
counter = int(time.time()) // 30
|
||
digest = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()
|
||
offset = digest[-1] & 0x0F
|
||
number = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
|
||
print(f"{number % 1_000_000:06d}")
|
||
PY
|
||
}
|
||
|
||
url_encode() {
|
||
python3 - "$1" <<'PY'
|
||
import sys
|
||
import urllib.parse
|
||
|
||
print(urllib.parse.quote(sys.argv[1], safe=""))
|
||
PY
|
||
}
|
||
|
||
assert_json_value() {
|
||
local expected=$1
|
||
shift
|
||
local actual
|
||
actual="$(json_value "$RESPONSE_BODY" "$@")"
|
||
[[ "$actual" == "$expected" ]] || fail "JSON value '$actual' did not equal '$expected'"
|
||
}
|
||
|
||
verify_privilege_matrix() {
|
||
mysql_root --batch --skip-column-names information_schema >"$ACTUAL_PRIVILEGES" <<'SQL'
|
||
SELECT TABLE_NAME, PRIVILEGE_TYPE
|
||
FROM TABLE_PRIVILEGES
|
||
WHERE GRANTEE = '''osg_smoke_runtime''@''%'''
|
||
AND TABLE_SCHEMA = 'osg_account_smoke'
|
||
ORDER BY TABLE_NAME, PRIVILEGE_TYPE;
|
||
SQL
|
||
|
||
python3 - "$SMOKE_DIR/runtime-grants.sql" "$ACTUAL_PRIVILEGES" <<'PY'
|
||
import re
|
||
import sys
|
||
|
||
grant_pattern = re.compile(
|
||
r"^GRANT\s+(.+?)\s+ON\s+osg_account_smoke\.([a-z0-9_]+)\s+TO\s+'osg_smoke_runtime'@'%';$",
|
||
re.IGNORECASE,
|
||
)
|
||
expected = set()
|
||
with open(sys.argv[1], encoding="utf-8") as grants_file:
|
||
statements = grants_file.read().split(";")
|
||
for statement in statements:
|
||
normalized = " ".join(
|
||
line.strip()
|
||
for line in statement.splitlines()
|
||
if line.strip() and not line.lstrip().startswith("--")
|
||
)
|
||
match = grant_pattern.match(f"{normalized};")
|
||
if match:
|
||
for privilege in match.group(1).split(","):
|
||
expected.add((match.group(2).lower(), privilege.strip().upper()))
|
||
|
||
actual = set()
|
||
for raw_line in open(sys.argv[2], encoding="utf-8"):
|
||
table, privilege = raw_line.rstrip("\n").split("\t")
|
||
actual.add((table.lower(), privilege.upper()))
|
||
|
||
missing = sorted(expected - actual)
|
||
unexpected = sorted(actual - expected)
|
||
if missing or unexpected:
|
||
print(f"missing privileges: {missing}", file=sys.stderr)
|
||
print(f"unexpected privileges: {unexpected}", file=sys.stderr)
|
||
raise SystemExit(1)
|
||
PY
|
||
|
||
local elevated_count
|
||
elevated_count="$(mysql_root --batch --skip-column-names information_schema <<'SQL'
|
||
SELECT
|
||
(SELECT COUNT(*) FROM USER_PRIVILEGES
|
||
WHERE GRANTEE = '''osg_smoke_runtime''@''%''' AND PRIVILEGE_TYPE <> 'USAGE')
|
||
+ (SELECT COUNT(*) FROM SCHEMA_PRIVILEGES
|
||
WHERE GRANTEE = '''osg_smoke_runtime''@''%''')
|
||
+ (SELECT COUNT(*) FROM mysql.procs_priv
|
||
WHERE User = 'osg_smoke_runtime' AND Host = '%');
|
||
SQL
|
||
)"
|
||
[[ "$elevated_count" == "0" ]] || fail "runtime user received global, schema, or routine privileges"
|
||
}
|
||
|
||
expect_runtime_denied() {
|
||
local description=$1
|
||
local statement=$2
|
||
if mysql_runtime osg_account_smoke --execute "$statement" \
|
||
>/dev/null 2>"$WORK_DIR/denied.err"; then
|
||
fail "$description unexpectedly succeeded"
|
||
fi
|
||
}
|
||
|
||
verify_immutable_history_denials() {
|
||
expect_runtime_denied \
|
||
"credit ledger UPDATE" \
|
||
"UPDATE credit_ledger SET amount_delta = amount_delta WHERE 1 = 0"
|
||
expect_runtime_denied \
|
||
"credit ledger DELETE" \
|
||
"DELETE FROM credit_ledger WHERE 1 = 0"
|
||
expect_runtime_denied \
|
||
"admin audit UPDATE" \
|
||
"UPDATE admin_audit_log SET outcome = outcome WHERE 1 = 0"
|
||
expect_runtime_denied \
|
||
"admin audit DELETE" \
|
||
"DELETE FROM admin_audit_log WHERE 1 = 0"
|
||
expect_runtime_denied \
|
||
"admin grant UPDATE" \
|
||
"UPDATE admin_credit_grants SET amount = amount WHERE 1 = 0"
|
||
expect_runtime_denied \
|
||
"admin grant DELETE" \
|
||
"DELETE FROM admin_credit_grants WHERE 1 = 0"
|
||
expect_runtime_denied \
|
||
"StoreKit purchase UPDATE" \
|
||
"UPDATE storekit_credit_purchases SET credits_granted = credits_granted WHERE 1 = 0"
|
||
expect_runtime_denied \
|
||
"StoreKit purchase DELETE" \
|
||
"DELETE FROM storekit_credit_purchases WHERE 1 = 0"
|
||
expect_runtime_denied \
|
||
"Flyway metadata read" \
|
||
"SELECT version FROM flyway_schema_history LIMIT 1"
|
||
}
|
||
|
||
verify_session_cleanup_permission() {
|
||
mysql_runtime osg_account_smoke \
|
||
--execute "DELETE FROM admin_sessions WHERE expires_at < UTC_TIMESTAMP() AND 1 = 0"
|
||
}
|
||
|
||
verify_ledger_pagination() {
|
||
local cursor first_id second_id encoded_cursor
|
||
http_request 200 GET "/v1/admin/users/$SMOKE_ACCOUNT_ID/ledger" "$SUPER_COOKIE_JAR" "" \
|
||
'X-OSG-mTLS-Verified: SUCCESS'
|
||
[[ "$(json_value "$RESPONSE_BODY" items | tr -d '\n')" != "" ]] || fail "ledger page was empty"
|
||
[[ "$(python3 - "$RESPONSE_BODY" <<'PY'
|
||
import json
|
||
import sys
|
||
print(len(json.load(open(sys.argv[1], encoding="utf-8"))["items"]))
|
||
PY
|
||
)" == "100" ]] || fail "first ledger page did not contain 100 entries"
|
||
first_id="$(json_value "$RESPONSE_BODY" items 0 entryId)"
|
||
cursor="$(json_value "$RESPONSE_BODY" nextCursor)"
|
||
[[ -n "$cursor" ]] || fail "first ledger page omitted nextCursor with more than 100 entries"
|
||
|
||
encoded_cursor="$(url_encode "$cursor")"
|
||
http_request 200 GET \
|
||
"/v1/admin/users/$SMOKE_ACCOUNT_ID/ledger?cursor=$encoded_cursor" \
|
||
"$SUPER_COOKIE_JAR" "" \
|
||
'X-OSG-mTLS-Verified: SUCCESS'
|
||
second_id="$(json_value "$RESPONSE_BODY" items 0 entryId)"
|
||
[[ -n "$second_id" && "$second_id" != "$first_id" ]] || fail "ledger cursor repeated the first page"
|
||
}
|
||
|
||
require_command curl
|
||
require_command docker
|
||
require_command openssl
|
||
require_command python3
|
||
|
||
APP_PORT="$(free_loopback_port)"
|
||
MYSQL_ROOT_PASSWORD="$(random_hex 24)"
|
||
MYSQL_RUNTIME_PASSWORD="$(random_hex 24)"
|
||
MYSQL_MIGRATION_PASSWORD="$(random_hex 24)"
|
||
JWT_SECRET="$(random_hex 32)"
|
||
FIELD_ENCRYPTION_KEY="$(random_base64_key)"
|
||
IDENTITY_HMAC_KEY="$(random_base64_key)"
|
||
|
||
cat >"$COMPOSE_ENV" <<EOF
|
||
SMOKE_RUN_ID=$RUN_ID
|
||
SMOKE_APP_PORT=$APP_PORT
|
||
SMOKE_MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD
|
||
SMOKE_MYSQL_MIGRATION_PASSWORD=$MYSQL_MIGRATION_PASSWORD
|
||
SMOKE_RUNTIME_ENV=$RUNTIME_ENV
|
||
SMOKE_ADMIN_ENV=$ADMIN_ENV
|
||
SMOKE_SECRET_DIR=$SECRET_DIR
|
||
SMOKE_HOST_UID=$(id -u)
|
||
SMOKE_HOST_GID=$(id -g)
|
||
EOF
|
||
|
||
cat >"$RUNTIME_ENV" <<EOF
|
||
APP_ENV=test
|
||
PORT=8080
|
||
PUBLIC_BASE_URL=http://127.0.0.1:$APP_PORT
|
||
INVITE_BASE_URL=https://osglab.com/i
|
||
APP_STORE_URL=https://apps.apple.com/us/app/smoke/id1
|
||
DATABASE_URL=jdbc:mysql://mysql:3306/osg_account_smoke?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true
|
||
DATABASE_USER=osg_smoke_runtime
|
||
DATABASE_PASSWORD=$MYSQL_RUNTIME_PASSWORD
|
||
DATABASE_POOL_SIZE=4
|
||
DATABASE_MIGRATION_USER=osg_smoke_migrator
|
||
DATABASE_MIGRATION_PASSWORD=$MYSQL_MIGRATION_PASSWORD
|
||
JWT_ISSUER=http://127.0.0.1:$APP_PORT
|
||
JWT_AUDIENCE=osg-smoke
|
||
JWT_SECRET=$JWT_SECRET
|
||
ACCESS_TOKEN_MINUTES=15
|
||
REFRESH_TOKEN_DAYS=1
|
||
GATEWAY_GRANT_DAYS=1
|
||
FIELD_ENCRYPTION_KEY=$FIELD_ENCRYPTION_KEY
|
||
IDENTITY_HMAC_KEY=$IDENTITY_HMAC_KEY
|
||
IDENTITY_TOMBSTONE_RETENTION_DAYS=1
|
||
APPLE_TEAM_ID=
|
||
APPLE_KEY_ID=
|
||
APPLE_CLIENT_ID=com.osgkeyboard.smoke
|
||
APPLE_PRIVATE_KEY_PEM=
|
||
APPLE_JWKS_URL=http://127.0.0.1:9/apple/jwks
|
||
APPLE_TOKEN_URL=http://127.0.0.1:9/apple/token
|
||
APPLE_REVOKE_URL=http://127.0.0.1:9/apple/revoke
|
||
APPLE_INTEGRITY_ENVIRONMENT=development
|
||
ENFORCE_DEVICE_CHECK=false
|
||
ENFORCE_APP_ATTEST=false
|
||
APP_ATTEST_CHALLENGE_TTL_SECONDS=30
|
||
VOLCENGINE_API_KEY=
|
||
VOLCENGINE_APP_ID=
|
||
VOLCENGINE_ACCESS_TOKEN=
|
||
VOLCENGINE_RESOURCE_ID=smoke-disabled
|
||
VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/volcengine
|
||
DEEPSEEK_API_KEY=
|
||
DEEPSEEK_MODEL=smoke-disabled
|
||
DEEPSEEK_ENDPOINT=http://127.0.0.1:9/deepseek
|
||
SIGNUP_TRIAL_CREDITS=100
|
||
REFERRAL_INVITER_CREDITS=100
|
||
REFERRAL_INVITEE_CREDITS=100
|
||
REFERRAL_BINDING_DAYS=1
|
||
ADMIN_SESSION_HOURS=1
|
||
ADMIN_MAXIMUM_MANUAL_GRANT=1000
|
||
EOF
|
||
chmod 600 "$COMPOSE_ENV" "$RUNTIME_ENV"
|
||
|
||
log "validating isolated Compose model"
|
||
compose --profile setup config --quiet
|
||
|
||
log "building local application image"
|
||
compose --profile setup build credential-generator
|
||
|
||
log "generating one-time administrator credentials"
|
||
compose --profile setup run --rm --no-deps credential-generator >/dev/null
|
||
mv "$ADMIN_GENERATED_ENV" "$ADMIN_ENV"
|
||
chmod 600 "$ADMIN_ENV" "$ADMIN_HANDOFF"
|
||
ADMIN_PASSWORD="$(awk -F ':' '/^密码:/{print $2}' "$ADMIN_HANDOFF")"
|
||
ADMIN_TOTP_SECRET="$(awk -F ':' '/^TOTP 密钥:/{print $2}' "$ADMIN_HANDOFF")"
|
||
[[ -n "$ADMIN_PASSWORD" && -n "$ADMIN_TOTP_SECRET" ]] || fail "administrator credential generation failed"
|
||
|
||
log "starting disposable MySQL 8.4"
|
||
compose up --detach --wait --wait-timeout 120 mysql
|
||
|
||
log "creating isolated migration and runtime users"
|
||
mysql_root <<SQL
|
||
CREATE USER 'osg_smoke_migrator'@'%' IDENTIFIED BY '$MYSQL_MIGRATION_PASSWORD';
|
||
CREATE USER 'osg_smoke_runtime'@'%' IDENTIFIED BY '$MYSQL_RUNTIME_PASSWORD';
|
||
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES, TRIGGER
|
||
ON osg_account_smoke.* TO 'osg_smoke_migrator'@'%';
|
||
SQL
|
||
|
||
log "applying Flyway migrations with the dedicated migrator"
|
||
compose --profile setup up --detach schema-migrator
|
||
wait_for_service_http schema-migrator 'http://127.0.0.1:8080/health/ready' ||
|
||
fail "schema migrator did not become ready"
|
||
|
||
MIGRATIONS="$(mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
|
||
SELECT CONCAT(version, ':', success)
|
||
FROM flyway_schema_history
|
||
WHERE version IS NOT NULL
|
||
ORDER BY installed_rank;
|
||
SQL
|
||
)"
|
||
EXPECTED_MIGRATIONS="$(seq 1 28 | awk '{ print $1 ":1" }')"
|
||
[[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] ||
|
||
fail "Flyway history was not exactly successful V1-V28"
|
||
REFERRAL_REWARDS="$(
|
||
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
|
||
SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits)
|
||
FROM referral_campaigns
|
||
WHERE id = '00000000-0000-0000-0000-000000000001';
|
||
SQL
|
||
)"
|
||
[[ "$REFERRAL_REWARDS" == "1000:1000" ]] ||
|
||
fail "default referral rewards were not 1000 credits for both accounts"
|
||
ACTIVE_RATES="$(
|
||
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
|
||
SELECT CONCAT_WS(
|
||
':',
|
||
kind,
|
||
provider,
|
||
model,
|
||
COALESCE(asr_credits_numerator, '-'),
|
||
COALESCE(asr_millis_denominator, '-'),
|
||
COALESCE(input_credits_numerator, '-'),
|
||
COALESCE(input_tokens_denominator, '-'),
|
||
COALESCE(output_credits_numerator, '-'),
|
||
COALESCE(output_tokens_denominator, '-')
|
||
)
|
||
FROM credit_rate_versions
|
||
WHERE effective_until IS NULL
|
||
ORDER BY kind, provider, model;
|
||
SQL
|
||
)"
|
||
EXPECTED_ACTIVE_RATES=$'ASR:volcengine-sauc-v3:volc.seedasr.sauc.duration:1:3000:-:-:-:-\nLLM:deepseek:deepseek-v4-flash:-:-:1:1000:1:400'
|
||
[[ "$ACTIVE_RATES" == "$EXPECTED_ACTIVE_RATES" ]] ||
|
||
fail "active smaller credit rates did not match the V10 contract"
|
||
compose --profile setup stop schema-migrator >/dev/null
|
||
|
||
log "installing exact runtime grants and disposable fixture"
|
||
mysql_root osg_account_smoke <"$SMOKE_DIR/runtime-grants.sql"
|
||
mysql_root osg_account_smoke <"$SMOKE_DIR/fixture.sql"
|
||
verify_privilege_matrix
|
||
|
||
log "starting application with runtime-only database access"
|
||
compose up --detach account-server
|
||
wait_for_service_http account-server 'http://127.0.0.1:8080/health/ready' ||
|
||
fail "account server did not become ready"
|
||
wait_for_host_http '/health/ready' ||
|
||
fail "account server loopback port did not become ready"
|
||
http_request 200 GET '/health/ready' - ''
|
||
assert_json_value UP status
|
||
|
||
log "verifying hidden admin edge and authenticated session"
|
||
http_request 404 GET '/v1/admin/auth/session' - ''
|
||
http_request 200 GET '/v1/admin/auth/session' - '' 'X-OSG-mTLS-Verified: SUCCESS'
|
||
assert_json_value false authenticated
|
||
|
||
ADMIN_TOTP_CODE="$(totp_code "$ADMIN_TOTP_SECRET")"
|
||
http_request 200 POST '/v1/admin/auth/login' "$SUPER_COOKIE_JAR" \
|
||
"{\"username\":\"smoke-admin\",\"password\":\"$ADMIN_PASSWORD\",\"totpCode\":\"$ADMIN_TOTP_CODE\"}" \
|
||
'X-OSG-mTLS-Verified: SUCCESS' \
|
||
"Origin: http://127.0.0.1:$APP_PORT"
|
||
assert_json_value SUPER_ADMIN role
|
||
CSRF_TOKEN="$(json_value "$RESPONSE_BODY" csrfToken)"
|
||
|
||
log "verifying CSRF, RBAC, core reads, and append-only grant path"
|
||
http_request 403 POST '/v1/admin/credits/grants' "$SUPER_COOKIE_JAR" \
|
||
"{\"userId\":\"$SMOKE_ACCOUNT_ID\",\"amount\":7,\"reason\":\"smoke grant\"}" \
|
||
'X-OSG-mTLS-Verified: SUCCESS' \
|
||
"Origin: http://127.0.0.1:$APP_PORT" \
|
||
'Idempotency-Key: smoke-grant-no-csrf'
|
||
assert_json_value CSRF_INVALID code
|
||
|
||
http_request 200 POST '/v1/admin/credits/grants' "$SUPER_COOKIE_JAR" \
|
||
"{\"userId\":\"$SMOKE_ACCOUNT_ID\",\"amount\":7,\"reason\":\"smoke grant\"}" \
|
||
'X-OSG-mTLS-Verified: SUCCESS' \
|
||
"Origin: http://127.0.0.1:$APP_PORT" \
|
||
"X-CSRF-Token: $CSRF_TOKEN" \
|
||
'Idempotency-Key: smoke-grant-success'
|
||
assert_json_value 108 balanceAfter
|
||
|
||
for path in \
|
||
'/v1/admin/overview?range=7d' \
|
||
'/v1/admin/referrals?range=7d' \
|
||
'/v1/admin/users' \
|
||
"/v1/admin/users/$SMOKE_ACCOUNT_ID" \
|
||
'/v1/admin/operators/summary' \
|
||
'/v1/admin/operators' \
|
||
'/v1/admin/audit'; do
|
||
http_request 200 GET "$path" "$SUPER_COOKIE_JAR" '' 'X-OSG-mTLS-Verified: SUCCESS'
|
||
done
|
||
|
||
http_request 201 POST '/v1/admin/operators' "$SUPER_COOKIE_JAR" \
|
||
'{"username":"smoke-analyst","password":"smoke-analyst-password","role":"ANALYST"}' \
|
||
'X-OSG-mTLS-Verified: SUCCESS' \
|
||
"Origin: http://127.0.0.1:$APP_PORT" \
|
||
"X-CSRF-Token: $CSRF_TOKEN"
|
||
ANALYST_TOTP_SECRET="$(json_value "$RESPONSE_BODY" totpSecret)"
|
||
ANALYST_TOTP_CODE="$(totp_code "$ANALYST_TOTP_SECRET")"
|
||
http_request 200 POST '/v1/admin/auth/login' "$ANALYST_COOKIE_JAR" \
|
||
"{\"username\":\"smoke-analyst\",\"password\":\"smoke-analyst-password\",\"totpCode\":\"$ANALYST_TOTP_CODE\"}" \
|
||
'X-OSG-mTLS-Verified: SUCCESS' \
|
||
"Origin: http://127.0.0.1:$APP_PORT"
|
||
http_request 403 GET '/v1/admin/users' "$ANALYST_COOKIE_JAR" '' \
|
||
'X-OSG-mTLS-Verified: SUCCESS'
|
||
assert_json_value INSUFFICIENT_PERMISSION code
|
||
|
||
if ! verify_ledger_pagination; then
|
||
FAILURES=$((FAILURES + 1))
|
||
printf '[smoke] ledger pagination verification failed\n' >&2
|
||
fi
|
||
|
||
verify_immutable_history_denials
|
||
verify_session_cleanup_permission
|
||
|
||
log "verifying logout and session revocation"
|
||
http_request 204 POST '/v1/admin/auth/logout' "$SUPER_COOKIE_JAR" '' \
|
||
'X-OSG-mTLS-Verified: SUCCESS' \
|
||
"Origin: http://127.0.0.1:$APP_PORT" \
|
||
"X-CSRF-Token: $CSRF_TOKEN"
|
||
ACTIVE_SUPER_SESSIONS="$(mysql_runtime --batch --skip-column-names osg_account_smoke <<'SQL'
|
||
SELECT COUNT(*)
|
||
FROM admin_sessions AS session
|
||
JOIN admin_operators AS operator ON operator.id = session.operator_id
|
||
WHERE operator.username = 'smoke-admin'
|
||
AND session.revoked_at IS NULL;
|
||
SQL
|
||
)"
|
||
[[ "$ACTIVE_SUPER_SESSIONS" == "0" ]] || fail "logout did not revoke the super-administrator session"
|
||
|
||
((FAILURES == 0)) || fail "$FAILURES smoke verification(s) failed"
|