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" /> -

-

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> { - const reader = response.body?.getReader() - if (!reader) throw new Error("unavailable") - const chunks: Uint8Array[] = [] - let size = 0 - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - size += value.length - if (size > 40_000) throw new Error("result") - chunks.push(value) - } - } finally { - await reader.cancel().catch(() => {}) - } - const bytes = new Uint8Array(size) - let offset = 0 - for (const chunk of chunks) { - bytes.set(chunk, offset) - offset += chunk.length - } - const body = JSON.parse(new TextDecoder().decode(bytes)) as Record< - string, - unknown - > - if (!response.ok) - throw new Error(typeof body.error === "string" ? body.error : "unavailable") - return body -} - export function ResultsLookup({ branch, onImport, @@ -109,14 +82,14 @@ export function ResultsLookup({ const [token, setToken] = useState("") const [ready, setReady] = useState(false) const [busy, setBusy] = useState(false) - const [error, setError] = useState("") + const [error, setError] = useState("") const [imported, setImported] = useState(false) const challengeRef = useRef(null) const widget = useRef(undefined) const controller = useRef(null) const generation = useRef(0) async function api(body: unknown, csrf?: string, signal?: AbortSignal) { - return readResponse( + return readLookupResponse( await fetch("/api/admissions/results", { method: "POST", headers: { @@ -133,12 +106,7 @@ export function ResultsLookup({ ) } function failure(reason: unknown) { - const code = reason instanceof Error ? reason.message : "unavailable" - setError( - ["limited", "challenge", "session", "result", "invalid"].includes(code) - ? code - : "unavailable" - ) + setError(lookupErrorCode(reason)) } function cancel() { generation.current++ @@ -246,7 +214,7 @@ export function ResultsLookup({ ) setCertificates(options) if (options.length === 1) setCertificate(String(options[0].id)) - if (!options.length) setError("result") + if (!options.length) setError("noCertificates") } catch (reason) { if (current === generation.current) failure(reason) } finally { @@ -279,7 +247,7 @@ export function ResultsLookup({ session.csrf, abort.signal ) - const upstream = await readResponse( + const upstream = await readLookupResponse( await fetch(String(issued.url), { headers: ministryHeaders, credentials: "omit", @@ -287,14 +255,9 @@ export function ResultsLookup({ redirect: "error", referrerPolicy: "no-referrer", signal: AbortSignal.any([abort.signal, AbortSignal.timeout(15_000)]), - }) + }), + "ministry" ) - if ( - upstream.success !== true || - typeof upstream.iv !== "string" || - typeof upstream.data !== "string" - ) - throw new Error("result") const result = await api( { action: "decode", @@ -305,9 +268,10 @@ export function ResultsLookup({ session.csrf, abort.signal ) - const marks = importedMarksSchema.parse(result.marks) + const parsed = importedMarksSchema.safeParse(result.marks) + if (!parsed.success) throw new LookupError("result") if (current !== generation.current) return - onImport(marks) + onImport(parsed.data) setImported(true) await changeOpen(false) } catch (reason) { @@ -368,7 +332,14 @@ export function ResultsLookup({ id="exam-certificate" disabled={busy || !certificates.length} value={certificate} - onValueChange={setCertificate} + onValueChange={(next) => { + setCertificate(next) + setError((current) => + current === "studentNumber" || current === "invalid" + ? "" + : current + ) + }} placeholder={t("choose")} items={certificates.map((value) => ({ value: String(value.id), @@ -376,18 +347,30 @@ export function ResultsLookup({ }))} /> - + {t("number")} setStudentNumber(event.target.value)} + onChange={(event) => { + setStudentNumber(event.target.value) + setError((current) => + current === "studentNumber" || current === "invalid" + ? "" + : current + ) + }} inputMode="numeric" dir="ltr" autoComplete="off" maxLength={12} - aria-invalid={error === "invalid"} + aria-invalid={ + error === "invalid" || error === "studentNumber" + } + aria-describedby={error ? "exam-lookup-error" : undefined} /> {session ? ( @@ -405,10 +388,8 @@ export function ResultsLookup({ ) : null} {error ? ( - - - {t(`errors.${error as "unavailable"}`)} - + + {t(`errors.${error}`)} ) : null} {busy ? ( diff --git a/src/app/[locale]/admissions/_utils/lookup-response.ts b/src/app/[locale]/admissions/_utils/lookup-response.ts new file mode 100644 index 0000000..e4954b4 --- /dev/null +++ b/src/app/[locale]/admissions/_utils/lookup-response.ts @@ -0,0 +1,105 @@ +import { encryptedEnvelopeSchema } from "../../../../lib/exam-results/contracts.ts" + +const errorCodes = [ + "unavailable", + "limited", + "challenge", + "session", + "result", + "invalid", + "studentNumber", + "ministry", + "noCertificates", + "timeout", +] as const +export type LookupErrorCode = (typeof errorCodes)[number] + +export class LookupError extends Error { + readonly code: LookupErrorCode + + constructor(code: LookupErrorCode) { + super(code) + this.code = code + } +} + +export function lookupErrorCode(reason: unknown): LookupErrorCode { + if (reason instanceof LookupError) return reason.code + if (reason instanceof Error && reason.name === "TimeoutError") + return "timeout" + return "unavailable" +} + +async function readJson(response: Response): Promise { + const reader = response.body?.getReader() + if (!reader) throw new Error("Empty response") + const chunks: Uint8Array[] = [] + let size = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.length + if (size > 40_000) throw new Error("Response too large") + chunks.push(value) + } + } finally { + await reader.cancel().catch(() => {}) + } + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.length + } + return JSON.parse(new TextDecoder().decode(bytes)) as unknown +} + +export async function readLookupResponse( + response: Response, + source: "app" | "ministry" = "app" +): Promise> { + // Preserve HTTP failures even when a proxy returns HTML or an empty body. + const fallback: LookupErrorCode = + response.status === 429 + ? "limited" + : [408, 504].includes(response.status) + ? "timeout" + : source === "ministry" + ? "ministry" + : "unavailable" + let value: unknown + try { + value = await readJson(response) + } catch (reason) { + throw new LookupError( + lookupErrorCode(reason) === "timeout" ? "timeout" : fallback + ) + } + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new LookupError(fallback) + const body = value as Record + if (source === "app") { + if (!response.ok || typeof body.error === "string") { + const code = errorCodes.find((code) => code === body.error) + throw new LookupError(code ?? fallback) + } + return body + } + + // Ministry application codes are a different contract from our API errors. + // In particular, 419 means an incorrect student number, not an expired session. + if (body.success === false) { + if (body.code === 419 || body.code === "419") + throw new LookupError("studentNumber") + if (body.code === 429 || body.code === "429") + throw new LookupError("limited") + throw new LookupError(fallback) + } + if (!response.ok || body.success !== true) throw new LookupError(fallback) + if ( + !encryptedEnvelopeSchema.safeParse({ iv: body.iv, data: body.data }).success + ) + throw new LookupError("ministry") + return body +} diff --git a/src/lib/exam-results/service.ts b/src/lib/exam-results/service.ts index 0ef4cd4..ec4a6dd 100644 --- a/src/lib/exam-results/service.ts +++ b/src/lib/exam-results/service.ts @@ -218,12 +218,19 @@ export class ResultsService { redirect: "error", signal: AbortSignal.timeout(10_000), }) + if (response.status === 429) throw new LookupError("limited", 429) if (!response.ok) throw new LookupError("unavailable", 503) - const body = await boundedJson(response, 48_000) - // Validate before storing; metadata contains no individual results. - if (path.startsWith("/directorateResultsNew/")) - directoratesSchema.parse(body) - else certificatesSchema.parse(body) + let body: unknown + try { + body = await boundedJson(response, 48_000) + // Validate before storing; metadata contains no individual results. + if (path.startsWith("/directorateResultsNew/")) + directoratesSchema.parse(body) + else certificatesSchema.parse(body) + } catch { + // A malformed provider response is not an error in the student's input. + throw new LookupError("unavailable", 503) + } this.store.cache(path, body) return body }