#!/usr/bin/env bash set -euo pipefail DB_CONTAINER="openccb-db" DB_NAME="openccb_lms" DB_USER="user" API_RUNNER_CONTAINER="${API_RUNNER_CONTAINER:-openccb-studio}" INTERNAL_BASE_URL="${INTERNAL_BASE_URL:-http://experience:3002}" JWT_SECRET="${JWT_SECRET:-}" if [[ -z "$JWT_SECRET" ]]; then if [[ -f .env ]]; then JWT_SECRET="$(grep -E '^JWT_SECRET=' .env | head -n1 | cut -d'=' -f2-)" fi fi if [[ -z "$JWT_SECRET" ]]; then echo "ERROR: JWT_SECRET not found (env or .env)" exit 1 fi if ! command -v jq >/dev/null 2>&1; then echo "ERROR: jq is required" exit 1 fi if ! command -v openssl >/dev/null 2>&1; then echo "ERROR: openssl is required" exit 1 fi if ! command -v docker >/dev/null 2>&1; then echo "ERROR: docker is required" exit 1 fi b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '=' } make_jwt() { local user_id="$1" local org_id="$2" local role="$3" local now exp header payload header_b64 payload_b64 signature now="$(date +%s)" exp="$((now + 86400))" header='{"alg":"HS256","typ":"JWT"}' payload="$(jq -cn \ --arg sub "$user_id" \ --arg org "$org_id" \ --arg role "$role" \ --argjson exp "$exp" \ '{sub:$sub,org:$org,exp:$exp,role:$role,course_id:null,token_type:"access"}')" header_b64="$(printf '%s' "$header" | b64url)" payload_b64="$(printf '%s' "$payload" | b64url)" signature="$(printf '%s' "${header_b64}.${payload_b64}" | openssl dgst -sha256 -hmac "$JWT_SECRET" -binary | b64url)" printf '%s.%s.%s' "$header_b64" "$payload_b64" "$signature" } run_sql() { local sql="$1" docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -t -A -c "$sql" } check_http() { local label="$1" local expected="$2" local token="$3" local path="$4" local method="${5:-GET}" local body="${6:-}" local response response="$(docker exec \ -e TARGET_URL="${INTERNAL_BASE_URL}${path}" \ -e METHOD="$method" \ -e TOKEN="$token" \ -e ORG_ID="$ORG_ID" \ -e REQ_BODY="$body" \ "$API_RUNNER_CONTAINER" \ node -e " const url = process.env.TARGET_URL; const method = process.env.METHOD || 'GET'; const token = process.env.TOKEN; const orgId = process.env.ORG_ID; const body = process.env.REQ_BODY || ''; const headers = { 'Authorization': 'Bearer ' + token, 'X-Organization-Id': orgId, }; const init = { method, headers }; if (body) { headers['Content-Type'] = 'application/json'; init.body = body; } fetch(url, init) .then(async (res) => { const text = await res.text(); process.stdout.write(String(res.status) + '\n'); process.stdout.write(text); }) .catch((err) => { console.error('FETCH_ERROR:' + err.message); process.exit(2); }); ")" local code code="$(printf '%s' "$response" | head -n1)" local response_body response_body="$(printf '%s' "$response" | tail -n +2)" local ok="0" IFS='|' read -r -a expected_codes <<< "$expected" for ec in "${expected_codes[@]}"; do if [[ "$code" == "$ec" ]]; then ok="1" break fi done if [[ "$ok" == "1" ]]; then echo "PASS [$label] -> HTTP $code" else echo "FAIL [$label] -> expected $expected, got $code" echo "Response body:" printf '%s\n' "$response_body" return 1 fi } cleanup() { if [[ "${KEEP_FIXTURES:-0}" == "1" ]]; then echo "KEEP_FIXTURES=1 -> skipping cleanup for debugging" return fi run_sql "DELETE FROM audio_responses WHERE id IN ('${RESP_A_ID}', '${RESP_B_ID}');" >/dev/null || true run_sql "DELETE FROM course_instructors WHERE course_id IN ('${COURSE_A_ID}', '${COURSE_B_ID}') AND user_id='${INSTRUCTOR_ID}';" >/dev/null || true run_sql "DELETE FROM lessons WHERE id IN ('${LESSON_A_ID}', '${LESSON_B_ID}');" >/dev/null || true run_sql "DELETE FROM modules WHERE id IN ('${MODULE_A_ID}', '${MODULE_B_ID}');" >/dev/null || true run_sql "DELETE FROM courses WHERE id IN ('${COURSE_A_ID}', '${COURSE_B_ID}');" >/dev/null || true run_sql "DELETE FROM users WHERE id IN ('${ADMIN_ID}', '${INSTRUCTOR_ID}', '${STUDENT_OWNER_ID}', '${STUDENT_OTHER_ID}');" >/dev/null || true } trap cleanup EXIT ORG_ID="00000000-0000-0000-0000-000000000001" ADMIN_ID="$(cat /proc/sys/kernel/random/uuid)" INSTRUCTOR_ID="$(cat /proc/sys/kernel/random/uuid)" STUDENT_OWNER_ID="$(cat /proc/sys/kernel/random/uuid)" STUDENT_OTHER_ID="$(cat /proc/sys/kernel/random/uuid)" COURSE_A_ID="$(cat /proc/sys/kernel/random/uuid)" COURSE_B_ID="$(cat /proc/sys/kernel/random/uuid)" MODULE_A_ID="$(cat /proc/sys/kernel/random/uuid)" MODULE_B_ID="$(cat /proc/sys/kernel/random/uuid)" LESSON_A_ID="$(cat /proc/sys/kernel/random/uuid)" LESSON_B_ID="$(cat /proc/sys/kernel/random/uuid)" BLOCK_A_ID="$(cat /proc/sys/kernel/random/uuid)" BLOCK_B_ID="$(cat /proc/sys/kernel/random/uuid)" RESP_A_ID="$(cat /proc/sys/kernel/random/uuid)" RESP_B_ID="$(cat /proc/sys/kernel/random/uuid)" SUFFIX="$(date +%s)" echo "Creating temporary LMS fixtures..." echo "ORG_ID=${ORG_ID}" echo "RESP_A_ID=${RESP_A_ID}" echo "RESP_B_ID=${RESP_B_ID}" run_sql " INSERT INTO organizations (id, name) VALUES ('${ORG_ID}', 'OpenCCB Default Org') ON CONFLICT (id) DO NOTHING; INSERT INTO users (id, email, password_hash, full_name, role, organization_id) VALUES ('${ADMIN_ID}', 'smoke-admin-${SUFFIX}@local.test', 'x', 'Smoke Admin', 'admin', '${ORG_ID}'), ('${INSTRUCTOR_ID}', 'smoke-instructor-${SUFFIX}@local.test', 'x', 'Smoke Instructor', 'instructor', '${ORG_ID}'), ('${STUDENT_OWNER_ID}', 'smoke-student-owner-${SUFFIX}@local.test', 'x', 'Smoke Student Owner', 'student', '${ORG_ID}'), ('${STUDENT_OTHER_ID}', 'smoke-student-other-${SUFFIX}@local.test', 'x', 'Smoke Student Other', 'student', '${ORG_ID}'); INSERT INTO courses (id, title, description, instructor_id, organization_id) VALUES ('${COURSE_A_ID}', 'Smoke Course A', 'Course with instructor access', '${INSTRUCTOR_ID}', '${ORG_ID}'), ('${COURSE_B_ID}', 'Smoke Course B', 'Course without instructor access', '${ADMIN_ID}', '${ORG_ID}'); INSERT INTO modules (id, course_id, title, position, organization_id) VALUES ('${MODULE_A_ID}', '${COURSE_A_ID}', 'Module A', 1, '${ORG_ID}'), ('${MODULE_B_ID}', '${COURSE_B_ID}', 'Module B', 1, '${ORG_ID}'); INSERT INTO lessons (id, module_id, title, content_type, position, organization_id) VALUES ('${LESSON_A_ID}', '${MODULE_A_ID}', 'Lesson A', 'video', 1, '${ORG_ID}'), ('${LESSON_B_ID}', '${MODULE_B_ID}', 'Lesson B', 'video', 1, '${ORG_ID}'); INSERT INTO course_instructors (organization_id, course_id, user_id, role) VALUES ('${ORG_ID}', '${COURSE_A_ID}', '${INSTRUCTOR_ID}', 'instructor'); INSERT INTO audio_responses ( id, organization_id, user_id, course_id, lesson_id, block_id, prompt, transcript, audio_data, ai_score, ai_found_keywords, ai_feedback, ai_evaluated_at, status, attempt_number, duration_seconds ) VALUES ( '${RESP_A_ID}', '${ORG_ID}', '${STUDENT_OWNER_ID}', '${COURSE_A_ID}', '${LESSON_A_ID}', '${BLOCK_A_ID}', 'Prompt A', 'Transcript A', convert_to('aGVsbG8=', 'UTF8'), 80, ARRAY['keyword'], 'good', now(), 'ai_evaluated', 1, 12 ), ( '${RESP_B_ID}', '${ORG_ID}', '${STUDENT_OWNER_ID}', '${COURSE_B_ID}', '${LESSON_B_ID}', '${BLOCK_B_ID}', 'Prompt B', 'Transcript B', convert_to('aGVsbG8=', 'UTF8'), 75, ARRAY['keyword'], 'pending review', now(), 'pending', 1, 11 ); " >/dev/null echo "Generating role tokens..." ADMIN_TOKEN="$(make_jwt "$ADMIN_ID" "$ORG_ID" "admin")" INSTRUCTOR_TOKEN="$(make_jwt "$INSTRUCTOR_ID" "$ORG_ID" "instructor")" OWNER_STUDENT_TOKEN="$(make_jwt "$STUDENT_OWNER_ID" "$ORG_ID" "student")" OTHER_STUDENT_TOKEN="$(make_jwt "$STUDENT_OTHER_ID" "$ORG_ID" "student")" echo "Running role-based smoke checks..." check_http "admin pending_instructor list" "200" "$ADMIN_TOKEN" "/audio-responses?status=pending_instructor" check_http "instructor list scoped" "200" "$INSTRUCTOR_TOKEN" "/audio-responses" check_http "instructor forbidden detail out-of-course" "403|404" "$INSTRUCTOR_TOKEN" "/audio-responses/${RESP_B_ID}" check_http "owner student audio access" "200" "$OWNER_STUDENT_TOKEN" "/audio-responses/${RESP_A_ID}/audio" check_http "other student denied audio access" "403" "$OTHER_STUDENT_TOKEN" "/audio-responses/${RESP_A_ID}/audio" check_http "instructor evaluate in-course" "200" "$INSTRUCTOR_TOKEN" "/audio-responses/${RESP_A_ID}/evaluate" "POST" '{"teacher_score":90,"teacher_feedback":"ok"}' check_http "instructor denied evaluate out-of-course" "403" "$INSTRUCTOR_TOKEN" "/audio-responses/${RESP_B_ID}/evaluate" "POST" '{"teacher_score":70,"teacher_feedback":"n/a"}' check_http "admin stats course A" "200" "$ADMIN_TOKEN" "/courses/${COURSE_A_ID}/audio-responses/stats" check_http "instructor denied stats course B" "403" "$INSTRUCTOR_TOKEN" "/courses/${COURSE_B_ID}/audio-responses/stats" echo "All smoke checks passed."