From 366de4c9894b185adfb0c07edd6d01a8c1a91b20 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:05:34 +0530 Subject: [PATCH 1/3] fix(quiz): say when a scheduled quiz opens; spend hit codes as the browser does A learner attempt recorded on Lighthouse on 2026-10-29 showed two gaps. - Scheduled open: before its open time a quiz's summary allows a start (canTakeQuiz and startQuiz true) but renders Start disabled and runs StartCountdown(<.NET ticks, UTC>) to enable it. attempt start gave the generic "cannot be started" refusal. It now reads the open time and refuses with "This quiz opens at HH:MM ZONE on D Month YYYY", plus "(in N seconds/minutes)" within the hour, counted on the server's clock from the summary's Date. With --json the error adds opens_at (UTC) and seconds_until_open. Nothing is sent, and the previous cursor is kept. - Hit codes: UI.Init spends one hit code as each page loads, so a window's first request ends in 2. The CLI's ended in 1, and its submit RPC reused the page's counter (3 after the finish save). Each page's FormProtection now starts there, and both ProcessQuizSubmission RPCs take their code from the attempt frame's own counter, as the browser sends them. .secrets.baseline: only the audited README entry's line number moved (365 -> 367) after the README sentence; no new findings. --- .secrets.baseline | 4 +- README.md | 4 +- docs/quiz-protocol.md | 15 ++++ lighthouse_cli/quiz_learner_commands.py | 3 + lighthouse_cli/quiz_learner_finish.py | 6 +- lighthouse_cli/quiz_learner_transport.py | 61 +++++++++++++-- lighthouse_cli/quiz_preview_finish.py | 6 +- lighthouse_cli/request_protection.py | 4 +- tests/test_quiz_learner.py | 98 ++++++++++++++++++++++-- tests/test_quiz_learner_session.py | 19 ++++- tests/test_quiz_preview_finish.py | 3 + tests/test_request_protection.py | 5 ++ 12 files changed, 204 insertions(+), 24 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 8a215cc..00bf6ec 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -149,7 +149,7 @@ "filename": "README.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 365, + "line_number": 367, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-10-26T17:57:39Z" + "generated_at": "2026-10-29T16:27:46Z" } diff --git a/README.md b/README.md index 0e455bc..182cae0 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,9 @@ lighthouse student attempt forget COURSE_ID QUIZ_ID --yes --json ``` `start` also returns the quiz's name, whether it is forward-only, its -attempt limit and its time limit in minutes (`time_limit_minutes`). Answers use the ids `page` returns: a choice id for a +attempt limit and its time limit in minutes (`time_limit_minutes`). Before a +quiz opens, `start` starts nothing and says when it opens (with `--json`, the +error's `opens_at` in UTC and `seconds_until_open`). Answers use the ids `page` returns: a choice id for a single-choice question, a list of option ids for a multi-select one, and a list of texts, one per blank, for a fill-in-the-blank one; questions left out keep their saved answers. Equations read as LaTeX and images as diff --git a/docs/quiz-protocol.md b/docs/quiz-protocol.md index 616141e..e05a89a 100644 --- a/docs/quiz-protocol.md +++ b/docs/quiz-protocol.md @@ -249,6 +249,14 @@ here are placeholders (`ou=1001`, `qi=2002`, `ai=3003`, question ids 4xxx). keeps it in the cursor. Whether a continued attempt's process page sets a new one was not recorded, so the CLI takes a new one if set and otherwise keeps the kept attempt's. +- Scheduled open: a summary read before the quiz opens still flags + `canTakeQuiz` and `startQuiz`, but renders `Start Quiz!` disabled and + calls `StartCountdown(<.NET ticks, UTC>);` on a line of its own (every + summary defines that function; only this one calls it). The script asks + the RPC `GetTimeUntilGivenDate` for the time left, counts down and then + enables the button, without reloading the page. The CLI reads the ticks + as data and, while Start is disabled, refuses with the open time and, + from the summary's `Date`, how long until then. - Attempt frame: the process page opens `attempt/quiz_attempt_iframe_auto.d2l?qi&ai&isprv=&dnb=0&drc=&pg=1&cfql=0&rldbsv=1&fromQB=0&cft=&d2l_body_type=1&ou` (Referer the start's second frame). Its frames load with the frame URL as @@ -337,6 +345,13 @@ here are placeholders (`ou=1001`, `qi=2002`, `ai=3003`, question ids 4xxx). (`%7B%22param1%22%3A%222002%22,%22param2%22%3A%223003%22,...%7D`), while `{ } " : [ ]` are percent-encoded. A form library's default encoding writes `%2C`, so the CLI encodes these values itself. +- Hit codes: `d2l_hitCode` (`d2l_hitcode` in the RPC) is the session's + seed, the time in milliseconds modulo 10^8 and a counter modulo 10 kept + per window. Each page's `UI.Init` spends one as it loads, so a window's + first request ends in 2: the summary's start POST (3 after a countdown, + whose RPC spent 2), a page's first save, and the submit RPC, which the + attempt frame sends from its own window however many the page has spent. + The CLI counts the same way. - Reloads: the CLI also reads pages a browser does not request again (the summary, each command's first read of the cursor's page, a readback after a save, `verify`'s reads). It sends those as a reload does, with diff --git a/lighthouse_cli/quiz_learner_commands.py b/lighthouse_cli/quiz_learner_commands.py index 4009151..efe3845 100644 --- a/lighthouse_cli/quiz_learner_commands.py +++ b/lighthouse_cli/quiz_learner_commands.py @@ -14,6 +14,7 @@ from .quiz_attempt_page import PreviewPageError, PreviewRefusedError from .quiz_learner_finish import LearnerNotSubmittedError, LearnerUnansweredError from .quiz_learner_session import UNCERTAIN, LearnerWorkflow, LearnerWorkflowError, parse_answers +from .quiz_learner_transport import LearnerNotOpenError _ID = click.IntRange(min=1, max=10**18 - 1) _CONFIRMED = {"start", "answer", "next", "previous", "submit", "forget"} @@ -80,6 +81,8 @@ def _execute(operation: str, course_id: int, quiz_id: int, json_output: bool, error: dict[str, Any] = {"mode": "learner", "course_id": course_id, "quiz_id": quiz_id, "error": message} if unanswered is not None: error["unanswered"] = unanswered + if isinstance(exc, LearnerNotOpenError): + error.update(exc.public_data()) fail(message + listed, json_output, error) diff --git a/lighthouse_cli/quiz_learner_finish.py b/lighthouse_cli/quiz_learner_finish.py index d034250..5062250 100644 --- a/lighthouse_cli/quiz_learner_finish.py +++ b/lighthouse_cli/quiz_learner_finish.py @@ -180,9 +180,11 @@ def submit_learner( confirmation, unanswered = _confirmation(client, protection, view, finish) if unanswered and not allow_unanswered: raise LearnerUnansweredError(unanswered) - # isRldbUse is the browser's Boolean() of a non-empty string. + # isRldbUse is the browser's Boolean() of a non-empty string. The + # attempt frame sends the call, its window's first request. + frame = FormProtection(protection.csrf_token, protection.hit_code_seed) body = rpc_body(view.quiz_id, view.attempt_id, rldb=bool(confirmation.get("HDN_isRldbUse", "")), - token=protection.csrf_token, hit_code=protection.next_hit_code()) + token=protection.csrf_token, hit_code=frame.next_hit_code()) rpc = attempt_frame_path(view.course_id, view.quiz_id, view.attempt_id, view.frame_page, rpc=True) response = client._request("POST", client.canonical_url(rpc), stream=True, data=body, headers={"Content-Type": "application/x-www-form-urlencoded", "Origin": client.base_url, diff --git a/lighthouse_cli/quiz_learner_transport.py b/lighthouse_cli/quiz_learner_transport.py index 656a1f0..25046a3 100644 --- a/lighthouse_cli/quiz_learner_transport.py +++ b/lighthouse_cli/quiz_learner_transport.py @@ -9,11 +9,13 @@ from __future__ import annotations import dataclasses +import math import posixpath import re import time from collections.abc import Callable, Mapping from dataclasses import dataclass, field +from datetime import datetime, timezone from email.utils import parsedate_to_datetime from typing import Any from urllib.parse import parse_qs, unquote, urldefrag, urlparse @@ -80,6 +82,28 @@ def __init__(self) -> None: super().__init__("Page change could not be verified. Run attempt start to reopen the attempt where Brightspace has it.") +class LearnerNotOpenError(PreviewRefusedError): + """A start before the quiz opens. ``opens_at`` is a Unix time; ``seconds_until_open`` is counted on the + server's clock, or ``None`` without its time. The message holds only these numbers.""" + + def __init__(self, opens_at: float, read_at: float | None) -> None: + self.opens_at = opens_at + self.seconds_until_open = None if read_at is None else max(0, math.ceil(opens_at - read_at)) + local = datetime.fromtimestamp(opens_at).astimezone() + relative = "" + if self.seconds_until_open is not None and 0 < self.seconds_until_open < 3600: + seconds = self.seconds_until_open + count, unit = (seconds, "second") if seconds < 60 else (math.ceil(seconds / 60), "minute") + relative = f" (in {count} {unit}{'' if count == 1 else 's'})" + super().__init__(f"This quiz opens at {local:%H:%M} {local:%Z} on {local.day} {local:%B %Y}{relative}. " + "Nothing was started.") + + def public_data(self) -> dict[str, Any]: + opens_at = datetime.fromtimestamp(self.opens_at, timezone.utc) + return {"opens_at": opens_at.isoformat(timespec="seconds").replace("+00:00", "Z"), + "seconds_until_open": self.seconds_until_open} + + REFUSE_START_UNAVAILABLE = "This quiz cannot be started or continued now (closed, not yet open or out of attempts)." REFUSE_NOTHING_IN_PROGRESS = "No attempt of this quiz is in progress." REFUSE_START_PASSWORD = "This quiz needs a password, which the CLI does not support." # pragma: allowlist secret @@ -108,9 +132,14 @@ def __init__(self) -> None: # .NET ticks (100 ns since 0001-01-01 UTC) at the Unix epoch. _UNIX_EPOCH_TICKS = 621_355_968_000_000_000 # How far ahead of the server's clock an attempt's start may be, and the -# earliest it may be (2000-01-01): an older one is no real start. +# earliest it may be (2000-01-01): an older one is no real start. A quiz's +# open time lies between that and the last second of 9999. _START_SKEW_SECONDS = 300 _EARLIEST_START = 946_684_800 +_LATEST_OPEN = 253_402_300_799 +# Before a quiz opens its summary calls the countdown that enables Start, +# with the open time in .NET ticks, on its own line. +_COUNTDOWN = re.compile(r"^[ \t]*StartCountdown\(\s*([0-9]{1,19})\s*\)\s*;[ \t\r]*$", re.MULTILINE) MAX_IMAGE_BYTES = 5 * 1024 * 1024 # Raster formats an agent can view, by their leading bytes. _IMAGE_SIGNATURES = ((b"\x89PNG\r\n\x1a\n", "image/png"), (b"\xff\xd8\xff", "image/jpeg"), @@ -164,6 +193,8 @@ class LearnerSummary: ``attempt_processing`` is the number of an attempt Brightspace is still submitting after its time ran out. Until it has, the summary flags Continue, yet that attempt cannot be continued and no new one started. + ``opens_at`` is the Unix time a quiz not yet open opens, and ``_read_at`` + the server's time when the page was read. """ course_id: int @@ -172,20 +203,33 @@ class LearnerSummary: can_continue: bool attempt_in_progress: int | None attempt_processing: int | None + opens_at: float | None _flags: dict[str, bool] = field(repr=False, compare=False) _hidden_fields: dict[str, str] = field(repr=False, compare=False) _password_input: bool = field(repr=False, compare=False) _button: str | None = field(repr=False, compare=False) _protection: FormProtection | None = field(repr=False, compare=False) _body: bytes = field(default=b"", repr=False, compare=False) + _read_at: float | None = field(default=None, repr=False, compare=False) def public_data(self) -> dict[str, Any]: return {"course_id": self.course_id, "quiz_id": self.quiz_id, "can_start": self.can_start, "can_continue": self.can_continue, "attempt_in_progress": self.attempt_in_progress, - "attempt_processing": self.attempt_processing} + "attempt_processing": self.attempt_processing, "opens_at": self.opens_at} + + +def _opens_at(scripts: str) -> float | None: + """The open time the summary counts down to, or ``None`` when it counts down to none.""" + found = {int(ticks) for ticks in _COUNTDOWN.findall(scripts)} + if not found: + return None + opens_at = (found.pop() - _UNIX_EPOCH_TICKS) / 10**7 + if found or not _EARLIEST_START <= opens_at <= _LATEST_OPEN: + raise PreviewPageError() + return opens_at -def parse_learner_summary(body: bytes, *, course_id: int, quiz_id: int) -> LearnerSummary: +def parse_learner_summary(body: bytes, *, course_id: int, quiz_id: int, read_at: float | None = None) -> LearnerSummary: """Read the summary page's start state as data; its scripts never run.""" identity(course_id, quiz_id) form, hidden = hidden_form(body) @@ -219,16 +263,16 @@ def parse_learner_summary(body: bytes, *, course_id: int, quiz_id: int) -> Learn can_start=takeable and flags["startQuiz"] and not flags["continueQuiz"], can_continue=takeable and flags["continueQuiz"] and not flags["startQuiz"] and not processing, attempt_in_progress=int(progress[0]) if progress else None, - attempt_processing=int(processing[0]) if processing else None, + attempt_processing=int(processing[0]) if processing else None, opens_at=_opens_at(scripts), _flags=flags, _hidden_fields=hidden, _password_input=bool(form.select('input[type="password"]') or soup.select('input[name="password"]')), - _button=labels[0] if labels else None, _protection=protection, _body=body, + _button=labels[0] if labels else None, _protection=protection, _body=body, _read_at=read_at, ) def read_learner_summary(client: LighthouseClient, *, course_id: int, quiz_id: int) -> LearnerSummary: - body, _ = _get_page(client, summary_path(course_id, quiz_id), _document_headers(client, reload=True)) - return parse_learner_summary(body, course_id=course_id, quiz_id=quiz_id) + body, headers = _get_page(client, summary_path(course_id, quiz_id), _document_headers(client, reload=True)) + return parse_learner_summary(body, course_id=course_id, quiz_id=quiz_id, read_at=_server_time(headers)) @dataclass(frozen=True) @@ -333,6 +377,9 @@ def _start_fields(summary: LearnerSummary, *, continue_only: bool) -> tuple[list if continue_only and not resume: raise PreviewRefusedError(REFUSE_NOTHING_IN_PROGRESS) if summary._button != ("Continue Quiz..." if resume else "Start Quiz!"): + # Start stays disabled until the quiz opens. + if not resume and summary._button is None and summary.opens_at is not None: + raise LearnerNotOpenError(summary.opens_at, summary._read_at) raise PreviewRefusedError(REFUSE_START_UNAVAILABLE) fields = summary._hidden_fields if fields.get("hps") or fields.get("LockDownBrowserUrl", "0") != "0": diff --git a/lighthouse_cli/quiz_preview_finish.py b/lighthouse_cli/quiz_preview_finish.py index 01ac4af..7a7ef0a 100644 --- a/lighthouse_cli/quiz_preview_finish.py +++ b/lighthouse_cli/quiz_preview_finish.py @@ -23,7 +23,7 @@ rpc_script, ) from .quiz_preview_transport import page_path, read_current_preview -from .request_protection import form_protection_from_homepage +from .request_protection import FormProtection, form_protection_from_homepage class PreviewSubmitUnknownError(NetworkError): @@ -120,12 +120,14 @@ def submit_preview( rpc_url = client.canonical_url(parent_path + "file?" + urlencode({**context, "d2l_rh": "rpc", "d2l_rt": "call"})) params = {"param1": str(quiz_id), "param2": str(attempt_id), "param3": True, "param4": retain, "param5": integration_flag, "param6": False, "param7": ""} + # The attempt frame sends the call, its window's first request. + frame = FormProtection(protection.csrf_token, protection.hit_code_seed) response = client._request("POST", rpc_url, stream=True, data={"d2l_rf": "ProcessQuizSubmission", # Brightspace rejects this RPC (error redirect) unless the # params JSON is compact, as the browser sends it. "params": json.dumps(params, separators=(",", ":")), - "d2l_referrer": protection.csrf_token, "d2l_hitcode": protection.next_hit_code(), + "d2l_referrer": protection.csrf_token, "d2l_hitcode": frame.next_hit_code(), "d2l_action": "rpc"}, headers={"Referer": client.canonical_url(parent_path + "?" + urlencode(context))}) expected = f"parent.QuizDone({quiz_id},{attempt_id},'1','0','0','gotoSv','')" diff --git a/lighthouse_cli/request_protection.py b/lighthouse_cli/request_protection.py index c4141f1..5d3db72 100644 --- a/lighthouse_cli/request_protection.py +++ b/lighthouse_cli/request_protection.py @@ -49,7 +49,9 @@ class FormProtection: csrf_token: str = field(repr=False) hit_code_seed: str = field(repr=False) _last_tick: int = field(default=-1, repr=False) - _counter: int = field(default=0, repr=False) + # Each instance is one page's: its UI.Init spends the first hit code as it + # loads, so the page's first request sends the second, as the browser does. + _counter: int = field(default=1, repr=False) _lock: Lock = field(default_factory=Lock, repr=False) def next_hit_code(self) -> str: diff --git a/tests/test_quiz_learner.py b/tests/test_quiz_learner.py index e7f67f7..930d67b 100644 --- a/tests/test_quiz_learner.py +++ b/tests/test_quiz_learner.py @@ -9,6 +9,7 @@ import json import re from dataclasses import replace +from datetime import datetime from email.utils import formatdate from html import escape from unittest.mock import Mock, patch @@ -81,6 +82,7 @@ REFUSE_START_ROLE, REFUSE_START_UNAVAILABLE, LearnerAdvanceUnknownError, + LearnerNotOpenError, LearnerSaveUnknownError, LearnerStartUnknownError, LearnerTimer, @@ -859,12 +861,15 @@ def test_other_rpc_replies_are_rejected(chunks): def summary(*, impersonating: str = "false", can_take: str = "true", start: str = "true", resume: str = "false", password: str = "false", button: str | None = "Start Quiz!", disabled: bool = False, progress: str = "", - fields: dict[str, str] | None = None, protected: bool = True, extra: str = "") -> bytes: + fields: dict[str, str] | None = None, protected: bool = True, extra: str = "", + countdown: str = "") -> bytes: hidden = {"d2l_action": "", "d2l_actionparam": "", "d2l_hitCode": "", "d2l_rf": "", "d2l_controlMapPrev": "", "hps": "", "drc": "0", "LockDownBrowserUrl": "0", "LockDownBrowserLaunchTimeout": "0", "d2l_controlMap": "[{},{}]", "d2l_state": '[{"1":["pagenum"]},[]]', "d2l_referrer": "SESSION_SENTINEL", **(fields or {})} - script = (f"" + f"" '') # The start button is rendered outside the form. @@ -875,6 +880,16 @@ def summary(*, impersonating: str = "false", can_take: str = "true", start: str IN_PROGRESS = {"start": "false", "resume": "true", "button": "Continue Quiz...", "progress": "Completed - 0 (Attempt 2 in progress)"} +# Before a quiz opens (observed on Lighthouse): Start is flagged but rendered +# disabled, and the summary counts down to the open time in .NET ticks. +OPENS_AT = 1_800_000_000 + + +def countdown(opens_at: float) -> str: + return f"\tStartCountdown({621_355_968_000_000_000 + int(opens_at * 10**7)});" + + +NOT_OPEN = {"disabled": True, "countdown": countdown(OPENS_AT)} # While Brightspace submits an attempt whose time ran out (observed in a # disposable Brightspace sandbox): Continue is flagged, Start Quiz! is disabled. PROCESSING = {"start": "false", "resume": "true", "disabled": True, @@ -884,7 +899,7 @@ def summary(*, impersonating: str = "false", can_take: str = "true", start: str def test_summary_reports_start_and_continue_state(): fresh = parse_learner_summary(summary(), course_id=10, quiz_id=20) assert fresh.public_data() == {"course_id": 10, "quiz_id": 20, "can_start": True, "can_continue": False, - "attempt_in_progress": None, "attempt_processing": None} + "attempt_in_progress": None, "attempt_processing": None, "opens_at": None} assert "SESSION_SENTINEL" not in repr(fresh) resumed = parse_learner_summary(summary(**IN_PROGRESS), course_id=10, quiz_id=20) assert (resumed.can_start, resumed.can_continue, resumed.attempt_in_progress) == (False, True, 2) @@ -897,7 +912,7 @@ def test_summary_reports_start_and_continue_state(): def test_an_attempt_brightspace_is_still_submitting_can_be_neither_continued_nor_started(page): processing = parse_learner_summary(page, course_id=10, quiz_id=20) assert processing.public_data() == {"course_id": 10, "quiz_id": 20, "can_start": False, "can_continue": False, - "attempt_in_progress": None, "attempt_processing": 10} + "attempt_in_progress": None, "attempt_processing": 10, "opens_at": None} @pytest.mark.parametrize("page", [ @@ -1038,6 +1053,8 @@ def test_start_posts_the_summary_form_once_and_opens_the_attempt_as_the_browser_ client._request.assert_called_once() call = client._request.call_args assert call.args == ("POST", BASE + "/d2l/lms/quizzing/user/quiz_summary.d2l?qi=20&ou=10&cfql=0&inProgress=false") + # The summary's own load spent its first hit code. + assert {key: value for key, (_, value) in call.kwargs["files"]}["d2l_hitCode"].endswith("2") assert call.kwargs["headers"] == {**FORM_HEADERS, "Referer": BASE + summary_path(10, 20)} assert sent(call) == [ ("d2l_action", "Custom"), ("d2l_actionparam", "1"), ("d2l_hitCode", "HIT"), ("d2l_rf", ""), @@ -1172,14 +1189,80 @@ def test_a_summary_just_read_is_not_requested_again(): (summary(**PROCESSING, password="true"), {}, REFUSE_START_PROCESSING.format(10)), # pragma: allowlist secret (summary(fields={"d2l_controlMap": "[{},{},{}]"}), {}, REFUSE_LEARNER_FORM), (summary(extra=''), {}, REFUSE_LEARNER_FORM), + # The open time is told only for a start the countdown holds back. + (summary(**NOT_OPEN), {"continue_only": True}, REFUSE_NOTHING_IN_PROGRESS), + (summary(**NOT_OPEN, can_take="false"), {}, REFUSE_START_UNAVAILABLE), + (summary(**NOT_OPEN, password="true"), {}, REFUSE_START_PASSWORD), # pragma: allowlist secret ]) def test_start_refusals_send_nothing(page, kwargs, message): client = write_client(page) - with pytest.raises(PreviewRefusedError, match=re.escape(message)): + with pytest.raises(PreviewRefusedError, match=re.escape(message)) as exc_info: start_learner(client, course_id=10, quiz_id=20, **kwargs) + assert not isinstance(exc_info.value, LearnerNotOpenError) + client._request.assert_not_called() + + +@pytest.mark.parametrize("calls", ["", "function StartCountdown(startDateTicks) {}", + "window.StartCountdown(639355968000000000);", "StartCountdown(1, 2);", + "var x = StartCountdown(639355968000000000);"]) +def test_a_summary_without_its_own_countdown_call_has_no_open_time(calls): + assert parse_learner_summary(summary(countdown=calls), course_id=10, quiz_id=20).opens_at is None + + +@pytest.mark.parametrize("calls", [countdown(OPENS_AT), countdown(OPENS_AT) + "\n" + countdown(OPENS_AT)]) +def test_a_summary_before_the_quiz_opens_names_the_open_time(calls): + page = parse_learner_summary(summary(disabled=True, countdown=calls), course_id=10, quiz_id=20) + assert (page.can_start, page.opens_at) == (True, OPENS_AT) + assert page.public_data()["opens_at"] == OPENS_AT + + +@pytest.mark.parametrize("calls", [ + countdown(OPENS_AT) + "\n" + countdown(OPENS_AT + 60), # two open times + countdown(946_684_799), # before 2000 + "StartCountdown(9999999999999999999);", # after 9999 +]) +def test_an_implausible_countdown_is_rejected(calls): + with pytest.raises(PreviewPageError): + parse_learner_summary(summary(disabled=True, countdown=calls), course_id=10, quiz_id=20) + + +@pytest.mark.parametrize("read_at, seconds, relative", [ + (OPENS_AT - 9, 9, " (in 9 seconds)"), + (OPENS_AT - 0.5, 1, " (in 1 second)"), + (OPENS_AT - 60, 60, " (in 1 minute)"), + (OPENS_AT - 61, 61, " (in 2 minutes)"), + (OPENS_AT - 3600, 3600, ""), + (OPENS_AT + 5, 0, ""), + (None, None, ""), +]) +def test_a_start_before_the_quiz_opens_says_when_and_sends_nothing(read_at, seconds, relative): + client = write_client() + page = parse_learner_summary(summary(**NOT_OPEN), course_id=10, quiz_id=20, read_at=read_at) + with pytest.raises(LearnerNotOpenError) as exc_info: + start_learner(client, course_id=10, quiz_id=20, summary=page) + local = datetime.fromtimestamp(OPENS_AT).astimezone() + assert str(exc_info.value) == (f"This quiz opens at {local:%H:%M} {local:%Z} on {local.day} {local:%B %Y}" + f"{relative}. Nothing was started.") + assert (exc_info.value.opens_at, exc_info.value.seconds_until_open) == (OPENS_AT, seconds) + assert exc_info.value.public_data() == {"opens_at": "2027-01-15T08:00:00Z", "seconds_until_open": seconds} client._request.assert_not_called() +def test_the_summary_s_server_time_counts_down_to_the_open_time(): + client = write_client() + client.get_raw = Mock(return_value=(summary(**NOT_OPEN), {"Date": formatdate(OPENS_AT - 9, usegmt=True)})) + with pytest.raises(LearnerNotOpenError) as exc_info: + start_learner(client, course_id=10, quiz_id=20) + assert exc_info.value.seconds_until_open == 9 + client._request.assert_not_called() + + +def test_an_enabled_start_is_posted_even_with_a_countdown(): + client = start_client(summary(countdown=countdown(OPENS_AT))) + assert start_learner(client, course_id=10, quiz_id=20).page.attempt_id == 30 + client._request.assert_called_once() + + @pytest.mark.parametrize("change", [ {"location": START_PROCESS.format(1).replace("quiz_start_process_auto", "quiz_start_frame_auto")}, {"location": START_PROCESS.format(0).replace("quiz_start_process_auto", "quiz_start_frame_auto").replace("isprv=", "isprv=1")}, @@ -1568,6 +1651,9 @@ def test_submit_saves_confirms_and_verifies_the_receipt_and_list_row(): assert save.kwargs["headers"] == {**FORM_HEADERS, "Referer": BASE + PAGE1} fields = dict(sent(save)) assert (fields["d2l_actionparam"], fields["QuizTabId"]) == ("5,1", TAB) + # The page spends its first hit code on this save, and the attempt + # frame its own first on the call below. + assert {key: value for key, (_, value) in save.kwargs["files"]}["d2l_hitCode"].endswith("2") confirm, submissions, receipt = client.get_raw.call_args_list assert confirm.args[0] == (ATTEMPT_ROUTE + "quiz_confirm_submit_auto.d2l?qi=20&ai=30&isprv=&drc=&impcf=&dnb=0&cfql=0" "&btlp=1&fromQB=0&cft=&d2l_body_type=3&ou=10") @@ -1579,7 +1665,7 @@ def test_submit_saves_confirms_and_verifies_the_receipt_and_list_row(): re.escape("d2l_rf=ProcessQuizSubmission¶ms=%7B%22param1%22%3A%2220%22,%22param2%22%3A%2230%22," "%22param3%22%3Afalse,%22param4%22%3Atrue,%22param5%22%3Atrue,%22param6%22%3Afalse," "%22param7%22%3A%22%22%7D&d2l_referrer=SESSION_SENTINEL&d2l_hitcode=") - + "1234567890[0-9]+&d2l_action=rpc", final.kwargs["data"]) + + "1234567890[0-9]+2&d2l_action=rpc", final.kwargs["data"]) assert final.kwargs["headers"] == {"Content-Type": "application/x-www-form-urlencoded", "Origin": BASE, "Referer": BASE + FRAME1} assert (submissions.args[0], submissions.kwargs["headers"]) == ( diff --git a/tests/test_quiz_learner_session.py b/tests/test_quiz_learner_session.py index c4e55f8..7c08082 100644 --- a/tests/test_quiz_learner_session.py +++ b/tests/test_quiz_learner_session.py @@ -45,6 +45,7 @@ REFUSE_START_PROCESSING, REFUSE_START_UNAVAILABLE, LearnerAdvanceUnknownError, + LearnerNotOpenError, LearnerSaveUnknownError, LearnerStart, LearnerStartUnknownError, @@ -213,15 +214,16 @@ def test_unlimited_attempts_read_as_none(): "name": None, "forward_only": False, "attempts_allowed": None, "time_limit_minutes": None} -def test_a_refused_start_keeps_the_previous_cursor(remote): +@pytest.mark.parametrize("refusal", [PreviewRefusedError(REFUSE_START_UNAVAILABLE), LearnerNotOpenError(1_800_000_000, None)]) +def test_a_refused_start_keeps_the_previous_cursor(remote, refusal): workflow = LearnerWorkflow(10, 20) - with patch(f"{SESSION}.start_learner", side_effect=PreviewRefusedError(REFUSE_START_UNAVAILABLE)): + with patch(f"{SESSION}.start_learner", side_effect=refusal): with pytest.raises(PreviewRefusedError): workflow.start() assert workflow.status()["status"] == "absent" start(workflow, open_page(2), resumed=True) with patch(f"{SESSION}.read_learner_summary", return_value=summary_state(resumed=True)), \ - patch(f"{SESSION}.start_learner", side_effect=PreviewRefusedError(REFUSE_START_UNAVAILABLE)): + patch(f"{SESSION}.start_learner", side_effect=refusal): with pytest.raises(PreviewRefusedError): workflow.start() assert workflow.status() == {**ACTIVE, "page": 2} @@ -1279,3 +1281,14 @@ def test_cli_errors_are_fixed_or_sanitized(): assert result.exit_code == 1 assert json.loads(result.stdout) == {"mode": "learner", "course_id": 10, "quiz_id": 20, "error": str(LearnerUnansweredError(unanswered)), "unanswered": unanswered} + + +def test_a_start_before_the_quiz_opens_reports_when_it_opens(): + refusal = LearnerNotOpenError(1_800_000_000, 1_800_000_000 - 9) + with patch("lighthouse_cli.quiz_learner_commands.LearnerWorkflow") as workflow: + workflow.return_value.start.side_effect = refusal + assert "(in 9 seconds). Nothing was started." in invoke("start", "10", "20", "--yes").stderr + result = invoke("start", "10", "20", "--yes", "--json") + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"mode": "learner", "course_id": 10, "quiz_id": 20, "error": str(refusal), + "opens_at": "2027-01-15T08:00:00Z", "seconds_until_open": 9} diff --git a/tests/test_quiz_preview_finish.py b/tests/test_quiz_preview_finish.py index 8444e51..3699cdc 100644 --- a/tests/test_quiz_preview_finish.py +++ b/tests/test_quiz_preview_finish.py @@ -44,6 +44,9 @@ def test_submit_is_preview_only_and_verifies_independent_receipt(): '{"param1":"20","param2":"30","param3":true,"param4":true,"param5":true,"param6":false,"param7":""}' ) assert client._request.call_count == 2 # one preparatory save, one final RPC + # Each window's first hit code: the page's save, then the attempt frame's call. + save = {key: value for key, (_, value) in client._request.call_args_list[0].kwargs["files"]} + assert save["d2l_hitCode"][-1] == rpc_call.kwargs["data"]["d2l_hitcode"][-1] == "2" prep.close.assert_called_once() rpc.close.assert_called_once() diff --git a/tests/test_request_protection.py b/tests/test_request_protection.py index 0762aab..384e7ac 100644 --- a/tests/test_request_protection.py +++ b/tests/test_request_protection.py @@ -64,6 +64,11 @@ def test_declarative_form_bootstrap_is_parsed_without_executing_code(): assert len({protection.next_hit_code() for _ in range(100)}) == 100 +def test_a_page_s_first_hit_code_is_its_second_as_page_init_spent_one(): + protection = form_protection_from_homepage(xsrf_init(1234567890)) + assert [protection.next_hit_code()[-1] for _ in range(11)] == list("23456789012") + + @pytest.mark.parametrize("seed", [1234567890, -1234567890, 0, 2**53 - 1, -(2**53) + 1]) def test_hit_codes_start_with_the_seed_as_written(seed): # Some accounts get a negative seed; the browser's hit code then starts From a7235d8150c1f504a380ca506bad08aa62f45b0c Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:27:03 +0530 Subject: [PATCH 2/3] fix(quiz): keep open times showable in every zone; test fixes from review Open times now end at 2999-12-31T23:59:59Z, not the last second of 9999: at 9999 the local-time message raised ValueError in every zone (found by GLM 5.3 and gpt-6.1-sol). The submit test parses its own page, since the shared one's hit codes are spent by tests that run before it in some orders (gpt-6.1-sol). A start into an attempt already in progress with a countdown on the page is still refused as unavailable, not as not open (GLM 5.3). --- lighthouse_cli/quiz_learner_transport.py | 5 +++-- tests/test_quiz_learner.py | 21 +++++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lighthouse_cli/quiz_learner_transport.py b/lighthouse_cli/quiz_learner_transport.py index 25046a3..b2c0620 100644 --- a/lighthouse_cli/quiz_learner_transport.py +++ b/lighthouse_cli/quiz_learner_transport.py @@ -133,10 +133,11 @@ def public_data(self) -> dict[str, Any]: _UNIX_EPOCH_TICKS = 621_355_968_000_000_000 # How far ahead of the server's clock an attempt's start may be, and the # earliest it may be (2000-01-01): an older one is no real start. A quiz's -# open time lies between that and the last second of 9999. +# open time lies between that and the end of 2999, a time every zone's local +# clock can still show. _START_SKEW_SECONDS = 300 _EARLIEST_START = 946_684_800 -_LATEST_OPEN = 253_402_300_799 +_LATEST_OPEN = 32_503_679_999 # Before a quiz opens its summary calls the countdown that enables Start, # with the open time in .NET ticks, on its own line. _COUNTDOWN = re.compile(r"^[ \t]*StartCountdown\(\s*([0-9]{1,19})\s*\)\s*;[ \t\r]*$", re.MULTILINE) diff --git a/tests/test_quiz_learner.py b/tests/test_quiz_learner.py index 930d67b..1ee9789 100644 --- a/tests/test_quiz_learner.py +++ b/tests/test_quiz_learner.py @@ -8,6 +8,7 @@ import json import re +import time from dataclasses import replace from datetime import datetime from email.utils import formatdate @@ -1191,6 +1192,7 @@ def test_a_summary_just_read_is_not_requested_again(): (summary(extra=''), {}, REFUSE_LEARNER_FORM), # The open time is told only for a start the countdown holds back. (summary(**NOT_OPEN), {"continue_only": True}, REFUSE_NOTHING_IN_PROGRESS), + (summary(**IN_PROGRESS, **NOT_OPEN), {}, REFUSE_START_UNAVAILABLE), (summary(**NOT_OPEN, can_take="false"), {}, REFUSE_START_UNAVAILABLE), (summary(**NOT_OPEN, password="true"), {}, REFUSE_START_PASSWORD), # pragma: allowlist secret ]) @@ -1219,13 +1221,27 @@ def test_a_summary_before_the_quiz_opens_names_the_open_time(calls): @pytest.mark.parametrize("calls", [ countdown(OPENS_AT) + "\n" + countdown(OPENS_AT + 60), # two open times countdown(946_684_799), # before 2000 - "StartCountdown(9999999999999999999);", # after 9999 + countdown(32_503_680_000), # 3000 or later + "StartCountdown(9999999999999999999);", ]) def test_an_implausible_countdown_is_rejected(calls): with pytest.raises(PreviewPageError): parse_learner_summary(summary(disabled=True, countdown=calls), course_id=10, quiz_id=20) +@pytest.mark.parametrize("zone", ["UTC", "Asia/Kolkata", "Pacific/Kiritimati", "America/Los_Angeles"]) +def test_the_latest_open_time_shows_in_every_zone(monkeypatch, zone): + monkeypatch.setenv("TZ", zone) + time.tzset() + try: + page = parse_learner_summary(summary(disabled=True, countdown=countdown(32_503_679_999)), course_id=10, + quiz_id=20) + assert str(LearnerNotOpenError(page.opens_at, None)).startswith("This quiz opens at ") + finally: + monkeypatch.undo() + time.tzset() + + @pytest.mark.parametrize("read_at, seconds, relative", [ (OPENS_AT - 9, 9, " (in 9 seconds)"), (OPENS_AT - 0.5, 1, " (in 1 second)"), @@ -1643,7 +1659,8 @@ def submit_client(*, confirm: bytes | None = None, result: str = "parent.QuizDon def test_submit_saves_confirms_and_verifies_the_receipt_and_list_row(): client, prep, rpc = submit_client() - result = submit_learner(client, VIEW, current=LAST) + # A page of its own: the shared one's hit codes are spent by other tests. + result = submit_learner(client, VIEW, current=parse(body(ANSWERED))) assert result == {"mode": "learner", "course_id": 10, "quiz_id": 20, "attempt_id": 30, "submitted": True, "attempt_number": 1, "score": 6.0, "out_of": 25.0, "percent": 24.0} save, final = client._request.call_args_list From ee7d5a171c17a7c36a4bfdf789ffc0f569cf276d Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:30:15 +0530 Subject: [PATCH 3/3] fix(quiz): keep the open time out of the summary's public data Nothing outside the tests reads LearnerSummary.public_data(); the open time reaches the user only through the not-open error (found by Droid). --- lighthouse_cli/quiz_learner_transport.py | 2 +- tests/test_quiz_learner.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lighthouse_cli/quiz_learner_transport.py b/lighthouse_cli/quiz_learner_transport.py index b2c0620..81ba76e 100644 --- a/lighthouse_cli/quiz_learner_transport.py +++ b/lighthouse_cli/quiz_learner_transport.py @@ -216,7 +216,7 @@ class LearnerSummary: def public_data(self) -> dict[str, Any]: return {"course_id": self.course_id, "quiz_id": self.quiz_id, "can_start": self.can_start, "can_continue": self.can_continue, "attempt_in_progress": self.attempt_in_progress, - "attempt_processing": self.attempt_processing, "opens_at": self.opens_at} + "attempt_processing": self.attempt_processing} def _opens_at(scripts: str) -> float | None: diff --git a/tests/test_quiz_learner.py b/tests/test_quiz_learner.py index 1ee9789..2cac638 100644 --- a/tests/test_quiz_learner.py +++ b/tests/test_quiz_learner.py @@ -900,7 +900,7 @@ def countdown(opens_at: float) -> str: def test_summary_reports_start_and_continue_state(): fresh = parse_learner_summary(summary(), course_id=10, quiz_id=20) assert fresh.public_data() == {"course_id": 10, "quiz_id": 20, "can_start": True, "can_continue": False, - "attempt_in_progress": None, "attempt_processing": None, "opens_at": None} + "attempt_in_progress": None, "attempt_processing": None} assert "SESSION_SENTINEL" not in repr(fresh) resumed = parse_learner_summary(summary(**IN_PROGRESS), course_id=10, quiz_id=20) assert (resumed.can_start, resumed.can_continue, resumed.attempt_in_progress) == (False, True, 2) @@ -913,7 +913,7 @@ def test_summary_reports_start_and_continue_state(): def test_an_attempt_brightspace_is_still_submitting_can_be_neither_continued_nor_started(page): processing = parse_learner_summary(page, course_id=10, quiz_id=20) assert processing.public_data() == {"course_id": 10, "quiz_id": 20, "can_start": False, "can_continue": False, - "attempt_in_progress": None, "attempt_processing": 10, "opens_at": None} + "attempt_in_progress": None, "attempt_processing": 10} @pytest.mark.parametrize("page", [ @@ -1215,7 +1215,6 @@ def test_a_summary_without_its_own_countdown_call_has_no_open_time(calls): def test_a_summary_before_the_quiz_opens_names_the_open_time(calls): page = parse_learner_summary(summary(disabled=True, countdown=calls), course_id=10, quiz_id=20) assert (page.can_start, page.opens_at) == (True, OPENS_AT) - assert page.public_data()["opens_at"] == OPENS_AT @pytest.mark.parametrize("calls", [