Refactor audio handling and S3 integration in LMS service
- Removed company-specific template rules from template application logic. - Enhanced question generation queries to support both 'imported-mysql' and 'imported-material' sources. - Introduced S3 audio storage functionality, including client setup and audio key generation. - Updated audio response evaluation to store audio files in S3 or fallback to DB. - Added new API routes for asset ingestion and ZIP import in CMS service. - Implemented role-based access control for audio responses in LMS service. - Created a smoke test script for validating audio roles and permissions. - Updated frontend to support course selection in audio evaluations.
This commit is contained in:
Executable
+242
@@ -0,0 +1,242 @@
|
||||
#!/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 (course_id, user_id, role)
|
||||
VALUES
|
||||
('${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."
|
||||
Reference in New Issue
Block a user