diff --git a/CHANGELOG.md b/CHANGELOG.md index f99bbc6..2be872a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Interpret Ministry lookup code 419 as an incorrect student number, distinguish service and response failures, and clear validation messages when lookup details change. +- Clarify the Other certificate guidance and remove the explanatory paragraph beneath the admissions submit button. - Add optional protected exam-marks import for the admissions advisor, preserving exact subject scores and manual editing. - Support validated operator-supplied public exam metadata when the provider rejects server connections. - Align admissions source links, add the Jobara footer partnership, and provide localized admissions sharing covers. diff --git a/docs/admissions-advisor.md b/docs/admissions-advisor.md index 808d973..7347cbe 100644 --- a/docs/admissions-advisor.md +++ b/docs/admissions-advisor.md @@ -237,6 +237,19 @@ Applicant category, personal eligibility details and official approvals are never inferred from the result. The browser-supplied response is advisory input, not authenticated proof of a certificate. Manual entry always remains available. +Lookup failures distinguish an incorrect student number (Ministry response code +419), temporary service errors, timeouts, rate limits, unavailable certificates +and results whose marks cannot be imported. The Ministry's application code is +checked separately from HTTP status and the local session/challenge errors. +Editing the number or certificate clears its validation message. Unexpected +provider payloads never become claims that the student's details are incorrect. +Messages remain localized; raw provider error bodies are not displayed or logged. + +The Other certificate option explains that the certificate is outside calculator +coverage and directs holders of officially equivalent non-Syrian certificates to +the corresponding branch. The explanatory paragraph beneath the main submit +button has been removed to keep the form compact. + The optional endpoint uses a verified Turnstile challenge, a session-bound single-use ticket and shared per-session, per-address and global limits. Only expiring hashes/counters and public exam metadata are stored on the server. diff --git a/messages/ar.json b/messages/ar.json index bacdb47..fcd39a6 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -23,7 +23,6 @@ "officialLink": "الإعلانات الرسمية", "previewTitle": "{count} رغبة ضمن المسارات المتاحة", "disclaimer": "استيفاء الحد الأدنى يسمح بالتقدّم ولا يضمن القبول؛ فالنتيجة تعتمد على ترتيب المتقدّمين والمقاعد المتاحة وبقية الشروط.", - "privacy": "تُحسب النتائج في متصفحك. يتضمن الرابط الفلاتر وإجابات شروط القبول. يُحفظ معدّلك في علامة التبويب هذه فقط، وتبقى المفضلة للزيارات القادمة. يعالج الاستيراد الاختياري النتيجة على خادمنا دون حفظها.", "source": "إعلان {number}، ص {page} من الملف", "reviewed": "آخر مراجعة: {date} · الإصدار {version}", "form": { @@ -31,7 +30,7 @@ "certificate": "الشهادة الثانوية", "supported": "الشهادة الثانوية السورية — العلمي", "otherCertificate": "شهادة أخرى", - "unsupported": "هذه الشهادة أو مسار القبول الخاص غير مدعوم بعد. راجع الشروط في الإعلانات الرسمية.", + "unsupported": "هذه الشهادة غير مشمولة بالحاسبة. إذا كانت شهادتك غير سورية ومعادلة رسمياً، اختر الفرع المعادل من القائمة.", "inputMode": "طريقة إدخال المعدّل", "percentage": "النسبة المئوية", "total": "المجموع من {maximum}", @@ -527,11 +526,15 @@ "success": "تمت تعبئة العلامات. راجعها قبل عرض الخيارات، ويمكنك تعديل أي علامة.", "errors": { "unavailable": "تعذّر الاتصال بخدمة النتائج. حاول لاحقاً أو أدخل علاماتك يدوياً.", - "limited": "تجاوزت المحاولات الحد المتاح. انتظر 15 دقيقة قبل المحاولة مجدداً، أو أدخل العلامات يدوياً.", + "limited": "طلبات كثيرة خلال وقت قصير. انتظر قبل المحاولة مجدداً، أو أدخل علاماتك يدوياً.", "challenge": "أكمل التحقق الأمني مجدداً. إذا تعذّر تحميله، يمكنك إدخال العلامات يدوياً.", "session": "انتهت صلاحية هذا الاستعلام. أغلق النافذة وافتحها مجدداً.", - "result": "تعذّر استيراد نتيجة نجاح مطابقة وبمقياس علامات مدعوم. تحقق من بيانات الامتحان أو أدخل العلامات يدوياً.", - "invalid": "تحقق من رقم الاكتتاب وبيانات الامتحان." + "result": "تعذّر التحقق من علامات هذه النتيجة لاستيرادها. أدخل علاماتك يدوياً.", + "invalid": "تحقق من رقم الاكتتاب وبيانات الامتحان.", + "studentNumber": "رقم الاكتتاب خاطئ. تحقق من الرقم والمحافظة والشهادة المختارة.", + "ministry": "لم تتمكن خدمة الوزارة من إرجاع النتيجة. حاول لاحقاً أو أدخل علاماتك يدوياً.", + "noCertificates": "لا تتوفر شهادة من هذا الفرع للاستيراد في المحافظة المختارة. تحقق من محافظة الامتحان أو أدخل علاماتك يدوياً.", + "timeout": "استغرق الطلب وقتاً طويلاً. حاول مجدداً أو أدخل علاماتك يدوياً." } } }, diff --git a/messages/en.json b/messages/en.json index f4b76b9..793b6bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -23,7 +23,6 @@ "officialLink": "Official announcements", "previewTitle": "{count} choices across admission routes", "disclaimer": "These are application minimums, not acceptance guarantees. Competition, available places, and all additional conditions still apply.", - "privacy": "Calculations run in your browser. Filters and eligibility answers are included in the URL. Your main score is saved only in this tab; favourites stay for future visits. Optional exam import processes the result on our server without saving it.", "source": "Announcement {number}, PDF page {page}", "reviewed": "Source check: {date} · {version}", "form": { @@ -31,7 +30,7 @@ "certificate": "Your certificate", "supported": "Syrian scientific certificate", "otherCertificate": "Another certificate", - "unsupported": "This certificate or special admission route is not yet supported. Use the official announcements for its requirements.", + "unsupported": "This certificate is outside the calculator’s coverage. If your non-Syrian certificate is officially recognised as equivalent, select its equivalent branch from the list.", "inputMode": "How would you like to enter your marks?", "percentage": "Percentage", "total": "Total out of {maximum}", @@ -527,11 +526,15 @@ "success": "Marks filled. Review them before showing your choices. You can edit any mark.", "errors": { "unavailable": "The lookup service could not be reached. Try again later or enter your marks manually.", - "limited": "Too many attempts. Wait 15 minutes before trying again, or enter your marks manually.", + "limited": "Too many requests in a short time. Wait before trying again, or enter your marks manually.", "challenge": "Please complete the security check again. If it does not load, use manual entry.", "session": "This lookup expired. Close this window and open it again.", - "result": "We could not import a matching passing result on the supported score scale. Check your exam details or enter your marks manually.", - "invalid": "Check the student number and exam selections." + "result": "We could not verify the marks in this result for import. Enter your marks manually.", + "invalid": "Check the student number and exam selections.", + "studentNumber": "The student number is incorrect. Check the number, exam governorate and selected certificate.", + "ministry": "The Ministry service could not return the result. Try again later or enter your marks manually.", + "noCertificates": "No certificates for this branch are available to import in the selected governorate. Check your exam governorate or enter your marks manually.", + "timeout": "The request took too long. Try again or enter your marks manually." } } }, diff --git a/scripts/test-exam-results.mjs b/scripts/test-exam-results.mjs index f3aea97..5a30095 100644 --- a/scripts/test-exam-results.mjs +++ b/scripts/test-exam-results.mjs @@ -11,6 +11,11 @@ import { parseStudentForm, } from "../src/app/[locale]/admissions/_utils/form.ts" import { applyImportedMarks } from "../src/app/[locale]/admissions/_utils/imported-marks.ts" +import { + LookupError, + lookupErrorCode, + readLookupResponse, +} from "../src/app/[locale]/admissions/_utils/lookup-response.ts" import { validateAdmissionsData } from "../src/app/[locale]/admissions/_utils/schema.ts" import { decodeMarks } from "../src/lib/exam-results/decoder.ts" import { boundedJson, ResultsService } from "../src/lib/exam-results/service.ts" @@ -25,6 +30,107 @@ const context = { } const certificate = "الثانوي - علمي - منهاج إدلب" const key = randomBytes(32) + +test("Ministry code 419 identifies an incorrect student number, independently of HTTP status", async () => { + for (const status of [200, 400, 419]) { + for (const code of [419, "419"]) { + await assert.rejects( + readLookupResponse( + Response.json( + { success: false, code, message: "رقم الاكتتاب خاطئ", data: null }, + { status } + ), + "ministry" + ), + (error) => + error instanceof LookupError && error.code === "studentNumber" + ) + } + } +}) + +test("lookup readers preserve rate limits and timeouts for non-JSON error responses", async () => { + for (const source of ["app", "ministry"]) { + for (const [status, code] of [ + [429, "limited"], + [504, "timeout"], + [408, "timeout"], + ]) { + await assert.rejects( + readLookupResponse( + new Response("Try later", { status }), + source + ), + (error) => error.code === code + ) + } + } + await assert.rejects( + readLookupResponse( + Response.json({ success: false, code: 429 }), + "ministry" + ), + (error) => error.code === "limited" + ) + assert.equal( + lookupErrorCode(new DOMException("Timed out", "TimeoutError")), + "timeout" + ) + assert.equal(lookupErrorCode(new TypeError("Failed to fetch")), "unavailable") +}) + +test("local API errors remain distinct from Ministry codes and unknown provider messages", async () => { + for (const code of [ + "invalid", + "session", + "challenge", + "result", + "unavailable", + "limited", + ]) { + await assert.rejects( + readLookupResponse(Response.json({ error: code }, { status: 400 })), + (error) => error.code === code + ) + } + for (const body of [ + { success: false, code: 500, message: "PRIVATE UPSTREAM DETAIL" }, + { success: false, error: "session", message: "PRIVATE UPSTREAM DETAIL" }, + { success: true, code: 419, data: null }, + ]) { + await assert.rejects( + readLookupResponse(Response.json(body), "ministry"), + (error) => error.code === "ministry" && !error.message.includes("PRIVATE") + ) + } +}) + +test("only valid successful Ministry envelopes can proceed to decoding", async () => { + const envelope = encrypt(fixture()) + assert.deepEqual( + await readLookupResponse( + Response.json({ success: true, ...envelope }), + "ministry" + ), + { success: true, ...envelope } + ) + for (const body of [ + null, + [], + { success: false }, + { success: true, iv: "bad", data: "bad" }, + ]) { + await assert.rejects( + readLookupResponse(Response.json(body), "ministry"), + (error) => error.code === "ministry" + ) + } + await assert.rejects( + readLookupResponse(new Response(`"${"a".repeat(40_000)}"`), "ministry"), + (error) => error.code === "ministry" + ) +}) + function fixture() { const rows = [ ["اللغة العربية", 350, 400], @@ -252,6 +358,41 @@ function setup(overrides = {}) { return { config, store, service, request, session, calls: () => calls } } +test("provider metadata errors never blame the student's input and do not get cached", async () => { + for (const [reply, expectedStatus, code] of [ + [() => new Response("Too many requests", { status: 429 }), 429, "limited"], + [ + () => new Response("Unavailable", { status: 503 }), + 503, + "unavailable", + ], + [() => new Response("not JSON"), 503, "unavailable"], + [() => Response.json({ success: false }), 503, "unavailable"], + [() => new Response(`"${"a".repeat(48_000)}"`), 503, "unavailable"], + ]) { + const s = setup() + let calls = 0 + const service = new ResultsService(s.config, s.store, async () => { + calls++ + return calls === 1 + ? reply() + : Response.json({ success: true, data: [{ id: 1, name: "Test" }] }) + }) + try { + const failed = await service.handle(s.request({ action: "session" })) + assert.equal(failed.status, expectedStatus) + assert.deepEqual(await failed.json(), { error: code }) + assert.equal( + (await service.handle(s.request({ action: "session" }))).status, + 200 + ) + assert.equal(calls, 2) + } finally { + s.store.close() + } + } +}) + test("private public-metadata snapshots avoid upstream fetches and reject incomplete or wrong-year lists", async () => { const s = setup() const directory = mkdtempSync(join(tmpdir(), "exam-metadata-")) diff --git a/src/app/[locale]/admissions/_components/marks-form.tsx b/src/app/[locale]/admissions/_components/marks-form.tsx index c943d01..a1ac7a2 100644 --- a/src/app/[locale]/admissions/_components/marks-form.tsx +++ b/src/app/[locale]/admissions/_components/marks-form.tsx @@ -7,13 +7,7 @@ const ResultsLookup = dynamic(() => import("./results-lookup").then((module) => module.ResultsLookup) ) -import { - ArrowRight, - BookOpen, - CircleCheck, - Compass, - ShieldCheck, -} from "lucide-react" +import { ArrowRight, BookOpen, CircleCheck, Compass } from "lucide-react" import { useLocale, useTranslations } from "next-intl" import { type FormEvent, useState } from "react" @@ -838,10 +832,6 @@ export function MarksForm({ className="rtl-icon-mirror" /> -
- - {t("privacy")} -
diff --git a/src/app/[locale]/admissions/_components/results-lookup.tsx b/src/app/[locale]/admissions/_components/results-lookup.tsx index 79f960b..82021bd 100644 --- a/src/app/[locale]/admissions/_components/results-lookup.tsx +++ b/src/app/[locale]/admissions/_components/results-lookup.tsx @@ -25,6 +25,12 @@ import { importedMarksSchema, ministryHeaders, } from "@/lib/exam-results/contracts" +import { + LookupError, + type LookupErrorCode, + lookupErrorCode, + readLookupResponse, +} from "../_utils/lookup-response" import { normaliseDigits } from "../_utils/score" import { AdmissionsSelect } from "./admissions-select" @@ -58,39 +64,6 @@ declare global { } } -async function readResponse( - response: Response -): Promise