#!/usr/bin/env python3 """ qa-learn.py — Falah Academy Micro Learning QA Test Suite 8-Layer Model: System, API, Render, Scenario, Edge, Mobile, Perf, Security Usage: python3 qa-learn.py [--url https://host:port] """ import urllib.request import urllib.error import json import sys import time import re import ssl import os BASE_URL = "http://localhost:3456" PASS = 0 FAIL = 0 SKIP = 0 RESULTS = [] ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE def req(path, method="GET", data=None, headers=None, expect_json=False): url = f"{BASE_URL}/mobile{path}" hdrs = { "User-Agent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36", "Accept": "application/json" if expect_json else "text/html,application/xhtml+xml", } if headers: hdrs.update(headers) body = json.dumps(data).encode() if data else None req = urllib.request.Request(url, data=body, headers=hdrs, method=method) try: resp = urllib.request.urlopen(req, timeout=15, context=ctx) text = resp.read().decode("utf-8", errors="replace") return {"status": resp.status, "text": text, "headers": dict(resp.headers)} except urllib.error.HTTPError as e: text = e.read().decode("utf-8", errors="replace") return {"status": e.code, "text": text, "headers": dict(e.headers)} except Exception as e: return {"status": 0, "text": str(e), "headers": {}} def check(name, condition, detail=""): global PASS, FAIL if condition: PASS += 1 RESULTS.append(f" ✅ PASS | {name}") else: FAIL += 1 RESULTS.append(f" ❌ FAIL | {name} | {detail}") def section(title): RESULTS.append(f"\n─── {title} ───") def json_body(resp): try: return json.loads(resp["text"]) if resp["text"].strip().startswith("{") else None except: return None # ════════════════════════════════════════ # LAYER 1: SYSTEM — Route existence # ════════════════════════════════════════ section("LAYER 1: SYSTEM — Route Existence") routes = [ ("/api/health", 200), ("/api/learn/courses", 200), ("/learn", 200), ("/verify/TEST-12345", 200), ] for path, expected in routes: r = req(path) check(f"GET {path} → HTTP {r['status']}", r["status"] == expected, f"Got {r['status']}") # Non-existent routes should 404 r = req("/api/learn/nonexistent") check("Non-existent API → 404", r["status"] == 404, f"Got {r['status']}") # ════════════════════════════════════════ # LAYER 2: API — Response shape contracts # ════════════════════════════════════════ section("LAYER 2: API — Response Shape") # Courses listing r = req("/api/learn/courses", expect_json=True) data = json_body(r) check("Courses returns JSON", data is not None, f"Got: {r['text'][:100]}") if data: courses = data.get("courses", []) check("Courses has 'courses' array", isinstance(courses, list)) check("Courses is not empty", len(courses) > 0, f"Got {len(courses)} courses") if courses: c = courses[0] check("Course has 'id'", "id" in c) check("Course has 'slug'", "slug" in c) check("Course has 'title'", "title" in c) check("Course has 'moduleCount'", "moduleCount" in c) check("Course has 'difficulty'", "difficulty" in c) check("Course slug is non-empty", bool(c.get("slug"))) # Course detail (requires auth → 401) r = req("/api/learn/courses/atomic-habits", expect_json=True) check("Course detail without auth → 401", r["status"] == 401, f"Got {r['status']}") # Verify endpoint public r = req("/api/learn/certificates/verify/FAL-TEST-00000-00000000-AAAAA", expect_json=True) data = json_body(r) check("Verify endpoint returns JSON (not HTML)", data is not None, f"Got: {r['text'][:100]}") if data: check("Verify returns 'valid' field", "valid" in data) check("Fake serial → valid=false", data.get("valid") is False, f"Got: {data}") # ════════════════════════════════════════ # LAYER 3: RENDER — Content scan # ════════════════════════════════════════ section("LAYER 3: RENDER — Content Scan") # Learn page should NOT have error strings r = req("/learn") error_strings = ["Internal Server Error", "Cannot read properties", "TypeError", "Error:"] for err in error_strings: if err in r["text"] and "Error: 404" not in r["text"]: check(f"Learn page: no '{err}' in HTML", False, f"Found: {err}") else: check(f"Learn page: no '{err}' in HTML", True) # Check for valid HTML structure check("Learn page: has ", "", "", " 0: check("4c: Quiz question has 'question'", "question" in quiz[0]) check("4c: Quiz question has 'options'", "options" in quiz[0]) check("4c: Quiz question has 'correctIndex'", "correctIndex" in quiz[0]) except: check("4c: Quiz parsing", False, "Invalid quiz JSON") # 4d. Enroll in a course (it's seeded, should be accessible) # Actually enroll endpoint might not exist as enroll - we enroll via webhook # Let's just verify the course data is complete r = req("/api/learn/courses", expect_json=True) data = json_body(r) if data: courses = data.get("courses", []) if courses: c = courses[0] check("4d: Course has modules", c.get("moduleCount", 0) > 0, f"moduleCount={c.get('moduleCount')}") check("4d: Course has description", bool(c.get("description"))) check("4d: Course has difficulty", c.get("difficulty") in ("beginner", "intermediate", "advanced")) # ════════════════════════════════════════ # LAYER 5: EDGE — Error handling # ════════════════════════════════════════ section("LAYER 5: EDGE — Edge Cases") # Empty slug → should 404 r = req("/api/learn/courses/") check("Empty slug → handled (200 or 404)", r["status"] in (200, 404), f"Got {r['status']}") # Non-existent slug r = req("/api/learn/courses/this-course-does-not-exist") check("Non-existent course → 401 (auth reqd)", r["status"] == 401, f"Got {r['status']}") # Invalid serial for verify r = req("/api/learn/certificates/verify/INVALID", expect_json=True) data = json_body(r) check("Invalid serial → {valid:false}", data is not None and data.get("valid") is False, f"Got: {r['text'][:100]}") # POST to GET-only endpoint → should 405 or gracefully handled r = req("/api/learn/courses", method="POST") check("POST to courses list", r["status"] in (405, 404, 400, 200), f"Got {r['status']}") # Query params injection r = req("/api/learn/courses?slug=atomic-habits'; DROP TABLE;--") check("SQL injection attempt handled", r["status"] != 500, f"Got {r['status']}") # Special characters in slug r = req("/api/learn/courses/../../../etc/passwd") check("Path traversal → handled (401/404 not 500)", r["status"] != 500, f"Got {r['status']}") # ════════════════════════════════════════ # LAYER 6: MOBILE — Mobile-first checks # ════════════════════════════════════════ section("LAYER 6: MOBILE — Mobile Readiness") r = req("/learn") html = r["text"] # Viewport meta check("Has viewport meta", 'name="viewport"' in html or 'name="viewport"' in html) # Touch-friendly: look for mobile-like containers check("Has mobile-container class", 'mobile-container' in html) # Safe area check("Has safe-area-bottom", 'safe-area-bottom' in html) # Dark theme check("Has dark theme class", 'className="dark"' in html or 'class="dark"' in html) # Check health endpoint returns quickly start = time.time() req("/api/health") elapsed = time.time() - start check(f"Health response < 2s", elapsed < 2.0, f"Took {elapsed:.2f}s") # ════════════════════════════════════════ # LAYER 7: PERF — Performance checks # ════════════════════════════════════════ section("LAYER 7: PERFORMANCE") for path, label in [("/api/learn/courses", "Courses list"), ("/learn", "Learn page")]: times = [] for _ in range(3): start = time.time() req(path) times.append(time.time() - start) avg = sum(times) / len(times) check(f"{label} avg response < 5s", avg < 5.0, f"Avg: {avg:.2f}s") # Payload size check r = req("/api/learn/courses") check("Courses API payload < 100KB", len(r["text"]) < 100_000, f"Size: {len(r['text'])} bytes") # ════════════════════════════════════════ # LAYER 8: SECURITY — Basic # ════════════════════════════════════════ section("LAYER 8: SECURITY — Basic Checks") # No secrets in HTML output r = req("/learn") secrets = ["JWT_SECRET", "DATABASE_URL", "POLAR_ACCESS_TOKEN", "flh_token="] for secret in secrets: if secret in r["text"]: check(f"No '{secret}' leaked in HTML", False, "SECRET LEAKED") break else: check("No secrets leaked in HTML", True) # No stack traces exposed r = req("/api/learn/courses/__non_existent__") check("No stack traces in error responses", "at" not in r["text"] or "Traceback" not in r["text"], "Stack trace might be exposed") # ════════════════════════════════════════ # SUMMARY # ════════════════════════════════════════ section("═══════════════════════════════════") section(f"QA SUMMARY: {PASS} passed, {FAIL} failed, {SKIP} skipped") grade = "A" if FAIL == 0 else "B" if FAIL <= 3 else "C" if FAIL <= 10 else "D" section(f"GRADE: {grade}") section(f"VERDICT: {'✅ DEPLOY OK' if grade in ('A', 'B') else '❌ BLOCKED — fix critical items'}") print("\n".join(RESULTS)) print(f"\nTotal: {PASS} passed, {FAIL} failed, {SKIP} skipped — Grade {grade}") sys.exit(0 if grade in ("A", "B") else 1)