Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions docs/admissions-advisor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 8 additions & 5 deletions messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,14 @@
"officialLink": "الإعلانات الرسمية",
"previewTitle": "{count} رغبة ضمن المسارات المتاحة",
"disclaimer": "استيفاء الحد الأدنى يسمح بالتقدّم ولا يضمن القبول؛ فالنتيجة تعتمد على ترتيب المتقدّمين والمقاعد المتاحة وبقية الشروط.",
"privacy": "تُحسب النتائج في متصفحك. يتضمن الرابط الفلاتر وإجابات شروط القبول. يُحفظ معدّلك في علامة التبويب هذه فقط، وتبقى المفضلة للزيارات القادمة. يعالج الاستيراد الاختياري النتيجة على خادمنا دون حفظها.",
"source": "إعلان {number}، ص {page} من الملف",
"reviewed": "آخر مراجعة: {date} · الإصدار {version}",
"form": {
"title": "أدخل بياناتك",
"certificate": "الشهادة الثانوية",
"supported": "الشهادة الثانوية السورية — العلمي",
"otherCertificate": "شهادة أخرى",
"unsupported": "هذه الشهادة أو مسار القبول الخاص غير مدعوم بعد. راجع الشروط في الإعلانات الرسمية.",
"unsupported": "هذه الشهادة غير مشمولة بالحاسبة. إذا كانت شهادتك غير سورية ومعادلة رسمياً، اختر الفرع المعادل من القائمة.",
"inputMode": "طريقة إدخال المعدّل",
"percentage": "النسبة المئوية",
"total": "المجموع من {maximum}",
Expand Down Expand Up @@ -527,11 +526,15 @@
"success": "تمت تعبئة العلامات. راجعها قبل عرض الخيارات، ويمكنك تعديل أي علامة.",
"errors": {
"unavailable": "تعذّر الاتصال بخدمة النتائج. حاول لاحقاً أو أدخل علاماتك يدوياً.",
"limited": "تجاوزت المحاولات الحد المتاح. انتظر 15 دقيقة قبل المحاولة مجدداً، أو أدخل العلامات يدوياً.",
"limited": "طلبات كثيرة خلال وقت قصير. انتظر قبل المحاولة مجدداً، أو أدخل علاماتك يدوياً.",
"challenge": "أكمل التحقق الأمني مجدداً. إذا تعذّر تحميله، يمكنك إدخال العلامات يدوياً.",
"session": "انتهت صلاحية هذا الاستعلام. أغلق النافذة وافتحها مجدداً.",
"result": "تعذّر استيراد نتيجة نجاح مطابقة وبمقياس علامات مدعوم. تحقق من بيانات الامتحان أو أدخل العلامات يدوياً.",
"invalid": "تحقق من رقم الاكتتاب وبيانات الامتحان."
"result": "تعذّر التحقق من علامات هذه النتيجة لاستيرادها. أدخل علاماتك يدوياً.",
"invalid": "تحقق من رقم الاكتتاب وبيانات الامتحان.",
"studentNumber": "رقم الاكتتاب خاطئ. تحقق من الرقم والمحافظة والشهادة المختارة.",
"ministry": "لم تتمكن خدمة الوزارة من إرجاع النتيجة. حاول لاحقاً أو أدخل علاماتك يدوياً.",
"noCertificates": "لا تتوفر شهادة من هذا الفرع للاستيراد في المحافظة المختارة. تحقق من محافظة الامتحان أو أدخل علاماتك يدوياً.",
"timeout": "استغرق الطلب وقتاً طويلاً. حاول مجدداً أو أدخل علاماتك يدوياً."
}
}
},
Expand Down
13 changes: 8 additions & 5 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,14 @@
"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": {
"title": "Start with your marks",
"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}",
Expand Down Expand Up @@ -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."
}
}
},
Expand Down
141 changes: 141 additions & 0 deletions scripts/test-exam-results.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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("<html>Try later</html>", { 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],
Expand Down Expand Up @@ -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("<html>Unavailable</html>", { 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-"))
Expand Down
12 changes: 1 addition & 11 deletions src/app/[locale]/admissions/_components/marks-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -838,10 +832,6 @@ export function MarksForm({
className="rtl-icon-mirror"
/>
</Button>
<p className="flex items-center justify-center gap-2 text-muted-foreground text-xs">
<ShieldCheck aria-hidden="true" className="size-4 shrink-0" />
{t("privacy")}
</p>
</CardFooter>
</Card>
</form>
Expand Down
Loading