From 6b3a1d858c0888785a5b72e37d147c11d79bb7a4 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:09:47 +0530 Subject: [PATCH 1/2] chore: make the CLI Lighthouse-only; drop the trial connection The CLI is for Lighthouse (MAHE Manipal). The Brightspace trial was only a test sandbox, so its code leaves the product: - Remove the 'trial' connection, every --site option (instructor/student groups, auth import-session) and the trial-only gate on instructor previews. Instructor previews become a Lighthouse instructor feature; an account without preview rights is refused before any write (the attempt listing or the missing Start control stops it). - connection.active_connection() returns Lighthouse. A private _override lets an out-of-repository harness point tests at a sandbox; it must use its own cookie directory, and such clients never refresh or migrate authentication. - JSON output drops the always-'lighthouse' "site" field (assessment envelopes, preview results, import-session). - Old trial cookies and checkpoints under sites/ are ignored, never migrated; origin binding still rejects foreign-origin artifacts. - Replace the trial evidence log docs/assessment-coverage.md with the generic docs/quiz-protocol.md; README examples use Lighthouse only. - Tests use the Lighthouse origin or a synthetic sandbox.example override. Refs #25 #31 Co-Authored-By: Claude Opus 5.5 --- .secrets.baseline | 6 +- README.md | 58 +++-- docs/assessment-coverage.md | 261 ----------------------- docs/quiz-protocol.md | 73 +++++++ lighthouse_cli/api.py | 9 +- lighthouse_cli/assessment_api.py | 2 +- lighthouse_cli/assessment_commands.py | 35 +-- lighthouse_cli/cli.py | 6 +- lighthouse_cli/connection.py | 29 +-- lighthouse_cli/quiz_preview_commands.py | 24 +-- lighthouse_cli/quiz_preview_session.py | 16 +- lighthouse_cli/quiz_preview_transport.py | 4 +- lighthouse_cli/session_import.py | 17 +- tests/test_assessment_workflows.py | 104 ++++++--- tests/test_quiz_attempt_page.py | 32 +-- tests/test_quiz_preview_finish.py | 8 +- tests/test_quiz_preview_session.py | 117 +++++----- tests/test_request_protection.py | 2 +- 18 files changed, 319 insertions(+), 484 deletions(-) delete mode 100644 docs/assessment-coverage.md create mode 100644 docs/quiz-protocol.md diff --git a/.secrets.baseline b/.secrets.baseline index 742a1c1..24557aa 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -149,7 +149,7 @@ "filename": "README.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 279, + "line_number": 275, "is_secret": false } ], @@ -179,7 +179,7 @@ "filename": "tests/test_assessment_workflows.py", "hashed_secret": "f714a8256826794cf5e9c3aa49c17d9ea53b8bd5", "is_verified": false, - "line_number": 102, + "line_number": 132, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-09-23T12:05:17Z" + "generated_at": "2026-09-24T11:36:09Z" } diff --git a/README.md b/README.md index 2ff0a08..5d2016d 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ lighthouse instructor assignment-create COURSE_ID --name 'Practice' --submission Both groups also provide `quiz`, `quizzes`, `assignment`, `assignments`, and the shared course reads above. Their JSON envelope is -`{"site": "lighthouse", "course_id": 123, "data": ...}`. Responses use a +`{"course_id": 123, "data": ...}`. Responses use a bounded field allowlist; unknown fields and credential-bearing properties are not returned. Classlists currently omit email and login identifiers. @@ -183,58 +183,54 @@ cookie-only endpoint; they reuse a token already held in memory without adding a homepage request. Tokens are held only in memory. Writes are not automatically replayed after network errors. -For the inspected Brightspace trial, put `--site trial` immediately after -`student` or `instructor`. Its cookies live in a separate encrypted directory -under `LIGHTHOUSE_CONFIG_DIR/sites/hetrynow.brightspace.com`. There is no -automatic fallback to Lighthouse cookies or to its browser-refresh mechanism. - -`lighthouse auth import-session --site trial --json` accepts an origin-bound -JSON object on **piped stdin**, shaped as `{"origin": "https://hetrynow.brightspace.com", +`lighthouse auth import-session --json` accepts an origin-bound JSON object on +**piped stdin**, shaped as `{"origin": "https://lighthouse.manipal.edu", "cookies": {...}}`, and seals it through `CredentialStore`. The cookie map must contain exactly the four required D2L session-cookie names. Do not put cookie values in arguments, shell history, or plaintext files. Import does not verify login; this command is not a browser-extension cookie-export tool. `LIGHTHOUSE_SECRETS_PASSPHRASE` or a supported OS keyring is required as usual. -An experimental **trial-only instructor preview** driver now supports start, -current-page reads, radio-answer saves with persisted readback, forward-only -navigation, and submission with a verified receipt: +An experimental **instructor quiz preview** driver (for accounts that can +preview a quiz) supports start, current-page reads, radio-answer saves with +persisted readback, forward-only navigation, and submission with a verified +receipt. Previews are not graded learner attempts: ```bash -lighthouse instructor --site trial preview start 22985 54488 --yes --json -lighthouse instructor --site trial preview page 22985 54488 --json +lighthouse instructor preview start COURSE_ID QUIZ_ID --yes --json +lighthouse instructor preview page COURSE_ID QUIZ_ID --json # Use question and choice IDs returned by page: -lighthouse instructor --site trial preview answer 22985 54488 QUESTION_ID CHOICE_ID --yes --json -# For the one-question/no-backtracking fixture (54489), use next after saving: -lighthouse instructor --site trial preview next 22985 54489 --yes --json -lighthouse instructor --site trial preview submit 22985 54488 --retain --yes --json +lighthouse instructor preview answer COURSE_ID QUIZ_ID QUESTION_ID CHOICE_ID --yes --json +# One question per page: advance after saving every answer on the page: +lighthouse instructor preview next COURSE_ID QUIZ_ID --yes --json +lighthouse instructor preview submit COURSE_ID QUIZ_ID --retain --yes --json ``` -These commands require a separately authenticated trial CLI session. They accept -untimed text/radio previews only. A sealed, account-bound cursor permits one -active preview per quiz; uncertain answer saves and completed submissions can -be verified with `page`, while uncertain navigation requires browser inspection -before continuing or abandoning. `status` reads the local cursor; `abandon` -forgets it without deleting the remote attempt. Starting another preview in the -browser can invalidate an unretained CLI preview. Write commands also support -`--dry-run`. A hidden quiz needs `start --bypass-availability`. +It accepts untimed text/radio previews only. A sealed, account-bound cursor +permits one active preview per quiz; uncertain answer saves and completed +submissions can be verified with `page`, while uncertain navigation requires +browser inspection before continuing or abandoning. `status` reads the local +cursor; `abandon` forgets it without deleting the remote attempt. Starting +another preview in the browser can invalidate an unretained CLI preview. Write +commands also support `--dry-run`. A hidden quiz needs +`start --bypass-availability`. If a start's outcome is uncertain, starting again is refused (even after `abandon`) until it is resolved with the read-only `reconcile` command: ```bash # Verify and resume a start that is bound to a known attempt, or list candidates: -lighthouse instructor --site trial preview reconcile 22985 54489 --json +lighthouse instructor preview reconcile COURSE_ID QUIZ_ID --json # Bind one listed candidate (checked for account, quiz, in-progress and preview mode): -lighthouse instructor --site trial preview reconcile 22985 54489 --attempt-id ATTEMPT_ID --json +lighthouse instructor preview reconcile COURSE_ID QUIZ_ID --attempt-id ATTEMPT_ID --json # Only after the browser shows no preview was created and none is listed: -lighthouse instructor --site trial preview reconcile 22985 54489 --confirm-no-remote-attempt --json +lighthouse instructor preview reconcile COURSE_ID QUIZ_ID --confirm-no-remote-attempt --json ``` Real learner quiz attempts, question authoring, teacher grading and full -course-administration parity are **not implemented** by these additions. -Instructor question definitions must not be treated as a student's currently -accessible attempt page. See [trial evidence and remaining coverage](docs/assessment-coverage.md). +course-administration parity are **not implemented** yet. Instructor question +definitions must not be treated as a student's currently accessible attempt +page. See [Brightspace assessment protocol notes](docs/quiz-protocol.md). --- diff --git a/docs/assessment-coverage.md b/docs/assessment-coverage.md deleted file mode 100644 index bb6c39b..0000000 --- a/docs/assessment-coverage.md +++ /dev/null @@ -1,261 +0,0 @@ -# Lighthouse student and instructor coverage - -Inspected on 2026-09-17. Product target: `lighthouse.manipal.edu`. The -`hetrynow.brightspace.com` trial is a fixture environment, not proof of Manipal -instructor permissions or complete platform parity. Starting revision: -`9dbee35`. Implementation branch: `feat/assessment-workflows`. - -## Evidence boundaries - -- University browser: authenticated Semester V learner. EEFM course `69472` - supplied the student baseline. No university assessments were modified or - submitted. -- Trial: instructor in personal **Build Your Course** (`22985`), with one - pre-enrolled **Sample Student**. The sample business course (`22984`) supplied - populated read-only examples for discussions, surveys, checklists and - submissions. Its existing content was not intentionally edited. -- The trial's classlist did not expose impersonation, and Admin Tools did not - expose user management. **View as Student** displayed the quiz summary but - no Start Quiz button. A separately authenticated learner is still needed. -- Instructor **Preview** did start, save and submit attempts. These are - explicitly previews (`isprv=1`), not genuine learner attempts. -- API probes ran in the connected browser with its current session. Local - CLI attempts used the existing sealed session with read-only auth. That - session decrypted using the workstation's local-secret injection but was - approximately 20.9 days old and returned 403 where the browser returned 200. - It was not replaced. Do not label these browser checks as successful - end-to-end local CLI authentication. - -## Trial fixtures retained for follow-up - -All fixtures below are in course `22985` and have no gradebook link. - -| Fixture | ID | Purpose | -| --- | --- | --- | -| CLI Sandbox - Live Attempt Test | Quiz `54488` | Two true/false questions, all visible together; visible in this trial course | -| CLI Sandbox - One Question No Backtracking | Quiz `54489` | Same two questions, one per page, backward navigation disabled; hidden, usable in instructor preview | -| CLI Sandbox - API Quiz Shell | Quiz `54490` | Hidden empty shell created using the CLI payload via browser-authenticated REST; two allowed attempts | -| CLI Sandbox - Assignment Submission Test | Folder `23865` | Visible individual file assignment created through UI | -| CLI Sandbox - API Text Assignment | Folder `23866` | Hidden individual text assignment created using the CLI payload via REST | - -Synthetic questions: “Two plus two equals four” (true), and “Three plus three -equals seven” (false). No real coursework was used. - -Preview `29191` tested the initial one-question fixture and returned 100%. -Preview `29192` tested the two-page, no-backtracking fixture and returned 100%. -The transition displayed a confirmation warning, then page two offered no -previous-page control. Both answers were visibly saved before submission. -Preview `29193` tested the expanded all-at-once fixture with both questions -visible in the same content frame and returned 100% after both answers were saved. -The option to publish preview grading in the Grade Quiz area remained off. - -## Confirmed API observations - -### Follow-up: supported student-perspective verification - -The supplied welcome email provides only the trial account, not a separate -learner login. [D2L's preview guide](https://community.d2l.com/brightspace/kb/articles/35012-preview-your-course) -distinguishes Role Switch (visibility checks), Preview (quiz interaction and -scoring), and Impersonate (student-account workflows). Preview does not cover -assignment submissions, learner accommodations or full downstream behavior. - -Live validation: on quiz `54488`, cleared **Bypass Restrictions**, answered both -questions, and enabled **Allow this preview attempt to be graded in the Grade -Quiz area** before submission. New attempt `29194` returned 100%, survived Exit -Preview, and appeared in **Grade Quiz > Users > Show Search Options > Users who -have previewed attempts** with 2/2 and 100%. Its evaluation page opened. -This retained synthetic attempt is intentional test evidence, with no gradebook -link. Earlier previews were submitted without retention and must not be relied -on as durable grading fixtures after Exit Preview. - -Correction to the earlier blocker: core quiz-flow development and teacher -evaluation testing can proceed with Preview. Separately authenticated learner -or authorized impersonation access remains necessary for student-specific -permissions, submissions, accommodations and downstream validation. No public -TryNow sample-student credentials or activation route were established by the -research. No request was sent to D2L; an administrator-provided learner account -or scoped sample-student impersonation is the remaining access path. - -API roots used: LE `1.93`, LP `1.47`. - -| Operation | University learner | Trial instructor | CLI implementation | -| --- | --- | --- | --- | -| Own assignment submissions | 200, empty for `46748` | Genuine learner submission not available | `student assignment-history` | -| Assignment definitions | Existing CLI support | 200, populated sample course | Both role groups; existing top-level command retained | -| Classlist | 200 | 200 | Both role groups | -| My sections | 200, one section | Not used as learner evidence | `student my-sections` | -| Group categories | 200, empty | 200, empty | Both role groups; groups selectable by category | -| Survey list | 200, empty | 200, two surveys | Lists and details | -| Checklist list | 200, empty | 200, one checklist; item fields inspected | Lists, details and items | -| Discussion forums | 200, empty | 200, populated forums/topics/posts | Hierarchical reads | -| Quiz question definitions | Not treated as learner access | 200 | `instructor quiz-questions` | -| Quiz attempt summaries | Not treated as learner access | 200, includes preview summaries | `instructor quiz-attempts` | -| Create assignment via cookie auth | No university write attempted | 403 without CSRF, 200 with CSRF | Hidden file/text creation | -| Create quiz via cookie auth | No university write attempted | 200 for final payload | Hidden shell creation, both layouts | -| Submit synthetic file | No university write attempted | 403 even with CSRF | Existing upload command uses the documented cookie-only endpoint; learner-role live validation remains blocked | -| Content userprogress route | 404 for inspected URL | 404 for inspected URL | Not added based on this failed probe | - -The homepage embeds a `localStorage.setItem('XSRF.Token', ...)` bootstrap in a -script. Its parsed value matched the active browser token without exposing -either value. Assessment creation bootstraps that value through a bounded -homepage GET, caches it per client, and clears it when cookies refresh. File -submission uses the documented cookie-only endpoint and can reuse a token -already held by the same client without adding a homepage request. No token is -logged or written in plaintext. - -The first quiz creation payload returned 400. Replacing its unenforced timing -and late-submission defaults with the accepted values in `quiz_payload()` -produced 200. The two changes were tested together; this is not proof that one -individual field caused the rejection. - -## Remaining work toward full website parity - -| Area | Missing workflows / validation | -| --- | --- | -| Learner quizzes | Genuine learner start, current-page questions, save confirmation, resume, one-way page advancement, timer expiry, final submission and receipt | -| Quiz authoring | Question creation/import/edit, sections/pools, full settings updates, special access, reports and grading | -| Assignments | Real learner file/text submission validation, attachment download from history, group submission, instructor feedback/rubric grading and publication | -| Discussions | Create/reply/edit, attachments, moderation, rating and subscription actions | -| Checklists and surveys | Learner completion/response and instructor authoring | -| Groups and sections | Membership changes, self-enrollment, teacher group creation and assignment | -| Grades and progress | Teacher gradebook mutation, rubric grading, complete learner progress APIs | -| Course authoring | Content creation/upload/reordering, announcements/calendar writes, files, links, import/export/copy, dates | -| Other teacher tools | Attendance, learning outcomes, intelligent agents, Quick Eval, tool settings | -| Account and institutional tools | Notifications/profile settings, discovery/awards, external integrations; capabilities and permissions require their own validation | - -These are open requirements, not completed features. The public quiz API -exposes authoring metadata and attempt summaries but no documented learner -start/answer/submit endpoints. The preview uses nested HTML frames and form -submissions such as `quiz_attempt_save_auto.d2l` and -`quiz_confirm_submit_auto.d2l`. Do not implement a guessed replay protocol or -use instructor question definitions to bypass learner paging. - -For an attempt driver, separate the current rendered page from quiz metadata. -Unknown paging/backtracking values must remain unknown. Save and confirm each -answer before advancing; one-way transitions must be explicit and cannot be -retried blindly. Final submission must return a verified receipt or an unknown -outcome, never a fabricated success. A future driver must test both layouts -against a genuine learner account as well as the instructor preview. - -## Experimental trial preview driver - -`instructor --site trial preview` now exposes `start`, `page`, `answer`, `next`, -`submit`, `status`, and local-only `abandon`. This is restricted to untimed -instructor previews with text/radio questions and the two requested layouts. -It is not a verified real-learner driver. The implementation separates bounded -HTML parsing, form protection, HTTP transitions, submission receipts, encrypted -cursor ownership, and Click wiring. No returned JavaScript is executed. - -Fresh per-request hit codes and dynamically mapped response-present fields were -required: an HTTP 200 alone did not prove an answer was saved. Save verification -reads back the selected choice and saved marker. Forward transitions require all -current answers saved. Start and writes are never automatically replayed; an -uncertain result leaves a durable checkpoint and blocks further writes. Final -submission requires a matching completed REST attempt record with verified -identifiers. The receipt heading is recorded when available but is not -required, because localized tenants render it differently. Unsupported media, -question types, timers, and session locking fail closed. - -Authenticated browser HTTP probes completed and retained these synthetic -previews on September 17, 2026: - -| Quiz | Attempt | Verified result | -| --- | --- | --- | -| One question per page, no backtracking (`54489`) | `29204` | Saved first answer, advanced, saved second answer, submitted; completed REST record, 2 points | -| All questions on one page (`54488`) | `29205` | Both answers persisted on readback; submission receipt and completed REST record, 2 points | - -The first probe exceeded the browser tool's observation timeout; a separate -read of the completed attempt resolved that uncertainty without repeating the -write. These are live protocol validations using the browser's authenticated -session, **not end-to-end runs of the installed Python CLI**. Stored terminal -cookies were stale (403), and a fresh trial CLI session has not been imported. -Python parsing, transport, cursor recovery, and JSON behavior are covered by -local tests. Real learner authorization and assignment submission still need -a learner login or authorized impersonation. - -### End-to-end CLI validation, 2026-09-23 - -The installed CLI now completes both layouts on the trial with an imported -trial session (issue #24). Two live defects were found and fixed first: the -question prompt arrives as a single `d2l-html-block` without the legacy -`d2l_read_element_` wrapper, and Brightspace rejects the `ProcessQuizSubmission` -RPC (error redirect) unless its `params` JSON is compact and the context names -the current page, as the browser sends it. - -| Scenario | Attempt | Result | -| --- | --- | --- | -| One question per page, full flow with `--retain` (`54489`) | `29226` | Saved, advanced, saved, submitted; 2 points, receipt verified, retained | -| All questions on one page (`54488`) | `29227` | Both saved, submitted; 2 points, receipt verified | -| Known-ID uncertain start (readback failure simulated after a real start) | `29228` | Identity sealed; start refused before and after `abandon`; `reconcile` resumed; 2 points | -| Lost start response (no identity), attempt advanced outside the CLI | `29229` | `reconcile` listed only the new attempt, bound it at the server-reported page 2; 2 points | -| Lost start response on the all-at-once quiz | `29231` | Candidate listed; `--confirm-no-remote-attempt` refused while a candidate existed; bound and submitted | - -Refusals made before any request (hidden quiz without `--bypass-availability`, -a question not on the current page, an unknown choice, unanswered questions, -not yet on the last page, `next` on the last page) now show specific messages -and leave the cursor unchanged. - -Observed server behavior relied on by recovery: for a forward-only quiz on -page 2, requesting page 1 returns page 2 (`pg=2`). Requesting a page past the -quiz's last page permanently breaks that preview attempt (every later read -redirects to `/d2l/error/500`); the CLI never requests one. Attempts `29225` -and `29230` were broken that way by manual probes. - -Helium was relaunched through its installed launcher using the active desktop's -Wayland environment; the ChatGPT extension reconnected automatically. No debug -port, alternate profile, or authentication settings were introduced. - -## Startup measurement - -Fresh Python processes on this workstation, medians (not network timings): - -| Invocation | Before | After lazy imports | -| --- | ---: | ---: | -| Root help | 269.1 ms | 64.4 ms | -| Version | 257.1 ms | 57.2 ms | -| Courses help | 250.3 ms | 62.6 ms | - -The new role groups are also lazy-loaded. Regression checks ensure importing -the CLI does not import requests, BeautifulSoup, Microsoft SSO, or assessment -implementations. File splitting alone is not counted as a latency improvement. - -## Local verification - -Full suite: `PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -q --p no:cacheprovider --basetemp=/var/tmp/lighthouse-pr-delivery-8sn7bl/full-tests-2079f33` -— **1,459 passed in 38.14 seconds**. The temporary test directory is disposable. -`ruff check --no-cache` passed for changed production modules and new tests; -`git diff --check` passed. The stacked PRs are open with hosted checks and -review bots still running; no merge or deployment has been performed. - -Tests cover lazy imports, both paging modes, unknown navigation rules, -same-origin pagination/cookies, sealed origin-bound imports, JSON errors, -CSRF bootstrap/caching, optional submission protection, session-expiry write -handling, and non-replayed submissions. -Existing multipart tests begin with a synthetic cached CSRF token; separate -request-protection tests exercise the new bootstrap path. - -Closeout: seven task-owned pytest directories under `/var/tmp` were audited -for live references and removed (66.33 MiB to zero). Source changes, this report -and the trial fixtures remain intentionally. The pre-existing repository -`.ruff_cache` was retained. The unrelated/unattributed `d2l-logo.png` (985 bytes) -was left untouched. During the earlier pass, temporary browser inspection tabs were closed and the -university tab was preserved. After the subsequent browser restart, the capture -tab was closed and the retained preview result (`29205`, 100%) was left open. - -## Sources - -- [D2L quizzes](https://docs.valence.desire2learn.com/res/quiz.html) -- [D2L assignments](https://docs.valence.desire2learn.com/res/dropbox.html) -- [D2L discussions](https://docs.valence.desire2learn.com/res/discuss.html) -- [D2L groups and sections](https://docs.valence.desire2learn.com/res/groups.html) -- [D2L checklists](https://docs.valence.desire2learn.com/res/checklist.html) -- [D2L surveys](https://docs.valence.desire2learn.com/res/survey.html) -- [D2L response on learner quiz API limitations](https://community.d2l.com/brightspace/discussion/7729/request-for-api-access-to-start-and-submit-quiz-attempts-via-rest-api) - -Follow-up closeout: `/var/tmp/lighthouse-quiz-parser-20260917` and -`/var/tmp/lighthouse-helium-launch-8lqhp74n.log` had no live references and were -removed (21,913,600 allocated bytes to zero). The failed launch was caused by -missing desktop display environment variables; the successful browser remains -open. Source, tests, this evidence report and the synthetic trial fixtures are -retained; `.ruff_cache` and `d2l-logo.png` remain untouched as noted above. diff --git a/docs/quiz-protocol.md b/docs/quiz-protocol.md new file mode 100644 index 0000000..89207cf --- /dev/null +++ b/docs/quiz-protocol.md @@ -0,0 +1,73 @@ +# Brightspace assessment protocol notes + +Findings behind the `student`/`instructor` assessment commands and the +instructor quiz-preview driver. They describe Brightspace (D2L) behavior, which +Lighthouse (MAHE Manipal) runs; they were established on a disposable +Brightspace sandbox and in passive observation of a real Lighthouse attempt. +The detailed evidence log is in the git history of +`docs/assessment-coverage.md` (removed 2026-09-24). + +API roots: LE `1.93`, LP `1.47`. + +## Writes and request protection + +- The homepage embeds a `localStorage.setItem('XSRF.Token', ...)` bootstrap. + Assessment creation reads it through a bounded homepage GET, caches it per + client, and sends `X-Csrf-Token`; cookie-only POSTs were rejected (403) + without it. File submission uses Brightspace's documented cookie-only + endpoint. Tokens are held only in memory and never logged. +- A quiz-creation payload with default timing and late-submission fields was + rejected (400); the values in `quiz_payload()` were accepted. + +## Quiz attempts (legacy HTML frames, not the REST API) + +The public quiz API exposes authoring metadata and attempt summaries, but no +documented learner start/answer/submit endpoints. Taking a quiz uses nested +HTML frames and form posts: + +1. Summary page `quiz_summary.d2l`, then a start POST (302) through + `quiz_start_frame_auto.d2l` and `quiz_start_iframe_2_auto.d2l` to + `quiz_start_process_auto.d2l`, whose script calls + `parent.GoToAttemptQuizAuto(attemptId, page, 0)`. That GET creates server + state and must never be replayed. +2. Page `quiz_attempt_page_auto.d2l?ou&qi&ai&pg&isprv`. Each question sits in + a `d2l-quiz-question-autosave-container` with hidden metadata (object id, + page, `tAtom` group, saved flag). The prompt is either a legacy + `d2l_read_element_*` element or a single `d2l-html-block` outside the + answer options. +3. Answer save: multipart POST to `quiz_attempt_save_auto.d2l` with a fresh + per-request hit code and the question's response-present flag. HTTP 200 + alone does not prove the answer persisted; read the page back and check the + selected choice and saved marker. +4. Forward navigation: the same save endpoint with `d2l_actionparam=2,...`. + Forward-only quizzes offer no previous-page control. Requesting a page + number past the quiz's last page permanently breaks that attempt (every + later read redirects to `/d2l/error/500`). +5. Submission: preparatory save, confirmation page + `quiz_confirm_submit_auto.d2l`, then RPC + `quiz_attempt_iframe_auto.d2lfile?...&pg=&d2l_rh=rpc&d2l_rt=call` + with `d2l_rf=ProcessQuizSubmission` and **compact** JSON `params` + (Brightspace answers spaced JSON with an error redirect). Success is the + callback `parent.QuizDone(quizId, attemptId, ...)`, then a receipt and a + completed REST attempt record. +6. Recovery: for a forward-only attempt on page 2, requesting page 1 returns + page 2 (`pg=2`); the preview driver's read-only `reconcile` relies on this + only after the full attempt identity matches. + +Instructor previews carry `isprv=1`. A real graded attempt observed on +Lighthouse used the same save, confirmation and submission routes, then a +receipt at `quiz_submissions_attempt.d2l?isprv=0`. Its start sequence, timers +and resume flow have not been observed yet. + +## Not yet implemented + +| Area | Missing workflows / validation | +| --- | --- | +| Learner quizzes | Real attempt start, resume, timers, receipt | +| Quiz authoring | Question creation/import/edit, sections/pools, settings updates, special access, grading | +| Assignments | Learner text submission, group submission, instructor feedback/rubric grading | +| Discussions | Create/reply/edit, attachments, moderation | +| Checklists and surveys | Learner completion/response and instructor authoring | +| Groups and sections | Membership changes, self-enrollment, group creation | +| Grades and progress | Gradebook changes, rubric grading, learner progress | +| Course authoring | Content creation/upload, announcements/calendar writes, import/export | diff --git a/lighthouse_cli/api.py b/lighthouse_cli/api.py index 0871bea..9a0859c 100644 --- a/lighthouse_cli/api.py +++ b/lighthouse_cli/api.py @@ -399,9 +399,9 @@ class LighthouseClient: session refresh so dry-run callers cannot modify local auth state. """ - def __init__(self, read_only_auth: bool = False, *, site: str = "lighthouse") -> None: - from .connection import connection_for - self.connection = connection_for(site) + def __init__(self, read_only_auth: bool = False) -> None: + from .connection import LIGHTHOUSE, active_connection + self.connection = active_connection() self.base_url = self.connection.origin self.api_le = self.connection.api_le self.cookie_host = self.connection.host @@ -410,7 +410,8 @@ def __init__(self, read_only_auth: bool = False, *, site: str = "lighthouse") -> self._loaded = False self._cache: dict[str, Any] = {} self._csrf_token: str | None = None - self._read_only_auth = bool(read_only_auth) or site != "lighthouse" + # Only the real Lighthouse connection may refresh or migrate auth. + self._read_only_auth = bool(read_only_auth) or self.connection != LIGHTHOUSE # -- cookie management -------------------------------------------------- diff --git a/lighthouse_cli/assessment_api.py b/lighthouse_cli/assessment_api.py index 6f70d9e..ada4fa1 100644 --- a/lighthouse_cli/assessment_api.py +++ b/lighthouse_cli/assessment_api.py @@ -47,7 +47,7 @@ def quiz_payload(name: str, layout: str, attempts: int) -> dict[str, Any]: "DueDate": None, "DisplayInCalendar": False, "NumberOfAttemptsAllowed": attempts, "LateSubmissionInfo": {"LateSubmissionOption": 0, "LateLimitMinutes": None}, - # These creation defaults were accepted by LE 1.93 in the trial. + # These creation defaults were accepted by Brightspace LE 1.93. # The duration is dormant because IsEnforced remains false. "SubmissionTimeLimit": {"IsEnforced": False, "ShowClock": False, "TimeLimitValue": 120}, "SubmissionGracePeriod": 0, "Password": None, diff --git a/lighthouse_cli/assessment_commands.py b/lighthouse_cli/assessment_commands.py index 68c7459..dde3c5a 100644 --- a/lighthouse_cli/assessment_commands.py +++ b/lighthouse_cli/assessment_commands.py @@ -5,7 +5,7 @@ import json import sys from collections.abc import Callable -from typing import Any, cast +from typing import Any import click @@ -30,22 +30,12 @@ def _emit(data: Any, json_output: bool) -> None: click.echo(json.dumps(data, indent=2, ensure_ascii=False, allow_nan=False)) -def _site() -> str: - context: click.Context | None = click.get_current_context() - while context is not None: - if "site" in context.params: - return cast(str, context.params["site"]) - context = context.parent - return "lighthouse" - - def _run(course_id: int, json_output: bool, action: Callable[[AssessmentAPI], Any]) -> None: client = None - site = _site() try: - client = LighthouseClient(site=site) + client = LighthouseClient() data = project(action(AssessmentAPI(client, course_id))) - _emit({"site": site, "course_id": course_id, "data": data}, json_output) + _emit({"course_id": course_id, "data": data}, json_output) except Exception as exc: message = ( "Write outcome unknown. Inspect the assessment before retrying." @@ -54,7 +44,7 @@ def _run(course_id: int, json_output: bool, action: Callable[[AssessmentAPI], An ) click.echo(message, err=True) if json_output: - output_json({"site": site, "course_id": course_id, "data": None, "error": message}) + output_json({"course_id": course_id, "data": None, "error": message}) raise SystemExit(1) from None finally: if client is not None: @@ -62,18 +52,16 @@ def _run(course_id: int, json_output: bool, action: Callable[[AssessmentAPI], An @click.group() -@click.option("--site", type=click.Choice(["lighthouse", "trial"]), default="lighthouse", show_default=True) -def instructor(site: str) -> None: +def instructor() -> None: """Inspect and author assessments with your account's course permissions. - The trial connection uses separate sealed cookies. Choosing this group - does not grant an instructor role or impersonate another user. + Choosing this group does not grant an instructor role or impersonate + another user; Lighthouse enforces your role in each course. """ @click.group() -@click.option("--site", type=click.Choice(["lighthouse", "trial"]), default="lighthouse", show_default=True) -def student(site: str) -> None: +def student() -> None: """Read learner assessment details and your own submission history.""" @@ -92,7 +80,7 @@ def invoke(self, ctx: click.Context) -> Any: return self._implementation().invoke(ctx) -instructor.add_command(_LazyPreview(name="preview", help="Experimental trial-only, checkpointed quiz previews.")) +instructor.add_command(_LazyPreview(name="preview", help="Experimental checkpointed instructor quiz previews.")) def _register_read(group: click.Group, name: str, resource: str, detail: bool) -> None: @@ -163,14 +151,13 @@ def classlist(course_id: int, json_output: bool) -> None: def _create(course_id: int, resource: str, payload: dict[str, Any], yes: bool, dry_run: bool, json_output: bool) -> None: - site = _site() if dry_run: - _emit({"site": site, "course_id": course_id, "dry_run": True, + _emit({"course_id": course_id, "dry_run": True, "operation": f"create-{resource}", "data": project(payload)}, json_output) return if not yes: if not sys.stdin.isatty() or not click.confirm( - f"Create a hidden {resource} on {site}, course {course_id}?", err=True, + f"Create a hidden {resource} in course {course_id}?", err=True, ): click.echo("Creation cancelled. Use --yes for non-interactive creation.", err=True) if json_output: diff --git a/lighthouse_cli/cli.py b/lighthouse_cli/cli.py index bf199c5..2d45f32 100644 --- a/lighthouse_cli/cli.py +++ b/lighthouse_cli/cli.py @@ -83,7 +83,6 @@ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None cli.add_command(_AssessmentGroup( name=_role, help=f"{_role.capitalize()} assessment workflows and submission records.", - params=[click.Option(["--site"], type=click.Choice(["lighthouse", "trial"]), default="lighthouse", show_default=True)], )) @@ -97,12 +96,11 @@ def auth() -> None: @auth.command("import-session", cls=JsonOutputCommand) -@click.option("--site", type=click.Choice(["lighthouse", "trial"]), required=True) @click.option("--json", "json_output", is_flag=True) -def auth_import_session(site: str, json_output: bool) -> None: +def auth_import_session(json_output: bool) -> None: """Import origin-bound cookies from JSON stdin into encrypted storage.""" from .session_import import import_session - click.get_current_context().invoke(import_session, site=site, json_output=json_output) + click.get_current_context().invoke(import_session, json_output=json_output) @auth.command("status", cls=JsonOutputCommand) diff --git a/lighthouse_cli/connection.py b/lighthouse_cli/connection.py index c8bf5ef..32fabe0 100644 --- a/lighthouse_cli/connection.py +++ b/lighthouse_cli/connection.py @@ -1,8 +1,7 @@ -"""Explicit sandbox connection settings; production remains the default.""" +"""The Lighthouse (MAHE Manipal) connection the CLI talks to.""" from __future__ import annotations -import os from dataclasses import dataclass from pathlib import Path from urllib.parse import urlsplit @@ -22,15 +21,19 @@ def api_le(self) -> str: return f"{self.origin}/d2l/api/le/1.93" -def connection_for(site: str) -> Connection: - """Keep alternate-site cookies out of the default authentication files. +LIGHTHOUSE = Connection("https://lighthouse.manipal.edu", None) - An explicit site is deliberately limited to the two inspected tenants. - Adding other tenants requires verifying their authentication contract. - """ - if site == "lighthouse": - return Connection("https://lighthouse.manipal.edu", None) - if site != "trial": - raise ValueError("Unknown connection. Choose lighthouse or trial.") - root = Path(os.getenv("LIGHTHOUSE_CONFIG_DIR", "~/.config/lighthouse-cli")).expanduser() - return Connection("https://hetrynow.brightspace.com", root / "sites" / "hetrynow.brightspace.com") +# Private seam for out-of-repository test harnesses only. When set, it must +# name its own cookie directory; clients built for it never refresh or +# migrate authentication (see LighthouseClient). +_override: Connection | None = None + + +def active_connection() -> Connection: + """Return the connection for this process: Lighthouse unless overridden.""" + override = _override + if override is None: + return LIGHTHOUSE + if not override.origin.startswith("https://") or override.cookie_dir is None: + raise ValueError("An overriding connection needs an HTTPS origin and its own cookie directory.") + return override diff --git a/lighthouse_cli/quiz_preview_commands.py b/lighthouse_cli/quiz_preview_commands.py index bfa9482..11e354c 100644 --- a/lighthouse_cli/quiz_preview_commands.py +++ b/lighthouse_cli/quiz_preview_commands.py @@ -1,4 +1,4 @@ -"""Explicit, checkpointed instructor-preview commands for the trial tenant.""" +"""Explicit, checkpointed instructor quiz-preview commands.""" from __future__ import annotations @@ -16,14 +16,13 @@ @click.group() -@click.pass_context -def preview(ctx: click.Context) -> None: - """Experimental trial-only quiz previews, not real learner attempts. +def preview() -> None: + """Experimental instructor quiz previews, not graded learner attempts. - Supports untimed text/radio questions in all-at-once and one-question, - no-backtracking layouts. Read page output before choosing answer IDs. + Needs an account that can preview the quiz. Supports untimed text/radio + questions in all-at-once and one-question, no-backtracking layouts. Read + page output before choosing answer IDs. """ - ctx.obj = {"preview_site": ctx.parent.params.get("site", "lighthouse") if ctx.parent else "lighthouse"} def _emit(value: dict[str, Any], structured: bool) -> None: @@ -35,21 +34,20 @@ def _emit(value: dict[str, Any], structured: bool) -> None: def _execute(operation: str, course_id: int, quiz_id: int, json_output: bool, yes: bool = False, dry_run: bool = False, **options: Any) -> None: - site = click.get_current_context().obj["preview_site"] if dry_run: - _emit({"site": site, "mode": "preview", "operation": operation, "course_id": course_id, + _emit({"mode": "preview", "operation": operation, "course_id": course_id, "quiz_id": quiz_id, "dry_run": True, "options": options}, json_output) return writes = operation not in {"page", "status", "reconcile"} if writes and not yes and (not sys.stdin.isatty() or not click.confirm( - f"Run preview {operation} on {site}, course {course_id}, quiz {quiz_id}?", err=True, + f"Run preview {operation} for course {course_id}, quiz {quiz_id}?", err=True, )): click.echo("Operation cancelled. Use --yes for non-interactive preview changes.", err=True) if json_output: output_json({"cancelled": True}) raise SystemExit(1) try: - workflow = PreviewWorkflow(site, course_id, quiz_id) + workflow = PreviewWorkflow(course_id, quiz_id) if operation == "status": result = workflow.status() elif operation == "abandon": @@ -58,14 +56,14 @@ def _execute(operation: str, course_id: int, quiz_id: int, json_output: bool, result = workflow.reconcile(**options) else: result = workflow.run(operation, **options) - _emit({"site": site, **result}, json_output) + _emit(result, json_output) except Exception as exc: # These carry only fixed, local messages; anything else is sanitized. fixed = (PreviewWorkflowError, PreviewRefusedError, PreviewPageError, *_UNCERTAIN) message = str(exc) if isinstance(exc, fixed) else format_user_error(exc) click.echo(message, err=True) if json_output: - output_json({"site": site, "mode": "preview", "course_id": course_id, "quiz_id": quiz_id, "error": message}) + output_json({"mode": "preview", "course_id": course_id, "quiz_id": quiz_id, "error": message}) raise SystemExit(1) from None diff --git a/lighthouse_cli/quiz_preview_session.py b/lighthouse_cli/quiz_preview_session.py index e602333..c8cab38 100644 --- a/lighthouse_cli/quiz_preview_session.py +++ b/lighthouse_cli/quiz_preview_session.py @@ -1,4 +1,4 @@ -"""Encrypted, single-writer cursors for experimental trial previews.""" +"""Encrypted, single-writer cursors for experimental instructor quiz previews.""" from __future__ import annotations @@ -12,7 +12,7 @@ from urllib.parse import parse_qs, urlparse from .api import LighthouseClient, _require_positive_endpoint_id -from .connection import connection_for +from .connection import active_connection from .credential_store import CredentialStore, _validate_credential_path from .quiz_attempt_page import PreviewPageError from .quiz_preview_finish import PreviewSubmitUnknownError, submit_preview, verify_receipt @@ -71,12 +71,10 @@ def _utc(value: object) -> datetime | None: class PreviewWorkflow: - def __init__(self, site: str, course_id: int, quiz_id: int) -> None: - if site != "trial": - raise PreviewWorkflowError("The experimental preview workflow currently requires --site trial.") + def __init__(self, course_id: int, quiz_id: int) -> None: page_path(course_id, quiz_id, 1, 1) - self.site, self.course_id, self.quiz_id = site, course_id, quiz_id - self.connection = connection_for(site) + self.course_id, self.quiz_id = course_id, quiz_id + self.connection = active_connection() self.store = CredentialStore(config_dir=self.connection.cookie_dir) self.path = self.store.config_dir / f"preview-{course_id}-{quiz_id}.json" self.lock_path = self.path.with_suffix(".lock") @@ -315,7 +313,7 @@ def reconcile(self, attempt_id: int | None = None, confirm_no_remote_attempt: bo state = self._load() if state is None or not self._unresolved_start(state): raise PreviewWorkflowError("Preview reconciliation applies only to an unresolved start.") - client = LighthouseClient(read_only_auth=True, site=self.site) + client = LighthouseClient(read_only_auth=True) try: if self._actor(client) != state["actor_id"]: raise PreviewWorkflowError("The saved preview belongs to a different signed-in account.") @@ -388,7 +386,7 @@ def run(self, operation: str, *, question_id: int | None = None, choice_id: int "The preview start is unresolved. Run preview reconcile before another write." ) raise PreviewWorkflowError("The last operation is uncertain. Inspect the browser before another write or abandon the preview.") - client = LighthouseClient(read_only_auth=True, site=self.site) + client = LighthouseClient(read_only_auth=True) state: dict[str, Any] | None = None try: actor = self._actor(client) diff --git a/lighthouse_cli/quiz_preview_transport.py b/lighthouse_cli/quiz_preview_transport.py index 5c1bb7a..036c39b 100644 --- a/lighthouse_cli/quiz_preview_transport.py +++ b/lighthouse_cli/quiz_preview_transport.py @@ -220,7 +220,7 @@ def read_server_current_preview( """Read-only recovery: return the attempt's server-side current page. Only for reconciling a start whose cursor is uncertain, never before a - write. Observed on the trial tenant (2026-09-23): for a forward-only quiz + write. Observed on Brightspace (2026-09-23): for a forward-only quiz already on page 2, requesting page 1 returns page 2 with ``pg=2``, and a page beyond the cursor redirects. An all-at-once quiz has a single page. The reported page is used only after the complete preview identity @@ -297,7 +297,7 @@ def advance_current_preview( current = read_current_preview(client, course_id=course_id, quiz_id=quiz_id, attempt_id=attempt_id, page=page) # advance_fields requires a Next control, so the page + 1 readback below # always exists. Never request a page beyond the quiz's last page: on the - # trial tenant (2026-09-23) that permanently breaks the preview attempt + # Brightspace (observed 2026-09-23) that permanently breaks the attempt # (every later read redirects to /d2l/error/500). fields = current.advance_fields(protection) url = client.canonical_url("/d2l/lms/quizzing/user/attempt/quiz_attempt_save_auto.d2l?" + urlencode({ diff --git a/lighthouse_cli/session_import.py b/lighthouse_cli/session_import.py index 5c5b620..9adf360 100644 --- a/lighthouse_cli/session_import.py +++ b/lighthouse_cli/session_import.py @@ -7,24 +7,23 @@ import click from .config import COOKIE_NAMES, missing_cookie_names -from .connection import connection_for +from .connection import active_connection from .credential_store import CredentialStore from .display import JsonOutputCommand, output_json, utc_now_iso from .utils import _loads_strict_json @click.command("import-session", cls=JsonOutputCommand) -@click.option("--site", type=click.Choice(["lighthouse", "trial"]), required=True) @click.option("--json", "json_output", is_flag=True) -def import_session(site: str, json_output: bool) -> None: +def import_session(json_output: bool) -> None: """Seal a session supplied as JSON on stdin; never supply cookies in argv. - Input shape: {"origin": "https://the-selected-site", "cookies": {...}}. - The origin must exactly match the selected site. This does not extract - browser cookies or prove the imported session is still authenticated. + Input shape: {"origin": "https://lighthouse.manipal.edu", "cookies": {...}}. + The origin must match exactly. This does not extract browser cookies or + prove the imported session is still authenticated. """ try: - connection = connection_for(site) + connection = active_connection() if sys.stdin.isatty(): raise ValueError() raw = sys.stdin.read(65537) @@ -53,6 +52,6 @@ def import_session(site: str, json_output: bool) -> None: output_json({"imported": False, "error": message}) raise SystemExit(1) from None if json_output: - output_json({"imported": True, "site": site, "verified": False}) + output_json({"imported": True, "verified": False}) else: - click.echo(f"Session sealed for {site}; authentication has not been verified.") + click.echo("Session sealed; authentication has not been verified.") diff --git a/tests/test_assessment_workflows.py b/tests/test_assessment_workflows.py index 8b0def9..e68e5ee 100644 --- a/tests/test_assessment_workflows.py +++ b/tests/test_assessment_workflows.py @@ -11,6 +11,7 @@ import requests from click.testing import CliRunner +from lighthouse_cli import connection from lighthouse_cli.api import LighthouseClient, NetworkError, SessionExpiredError from lighthouse_cli.assessment_api import ( AssessmentAPI, @@ -21,7 +22,7 @@ ) from lighthouse_cli.cli import cli from lighthouse_cli.config import COOKIE_NAMES -from lighthouse_cli.connection import connection_for +from lighthouse_cli.connection import LIGHTHOUSE, Connection, active_connection from lighthouse_cli.credential_store import CredentialStore from lighthouse_cli.quiz_rules import navigation_rules @@ -45,32 +46,61 @@ def test_unknown_rules_do_not_grant_navigation(paging, back): assert rules["can_revisit_previous_pages"] is None -def test_trial_urls_cookies_and_pagination_are_origin_scoped(): - client = LighthouseClient(site="trial") - assert client.canonical_url("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/22985/quizzes/") == "https://hetrynow.brightspace.com/d2l/api/le/1.93/22985/quizzes/" +SANDBOX_ORIGIN = "https://sandbox.example" + + +@pytest.fixture +def sandbox(tmp_path, monkeypatch): + """Install the private test-harness override with its own cookie directory.""" + override = Connection(SANDBOX_ORIGIN, tmp_path / "sandbox") + monkeypatch.setattr(connection, "_override", override) + return override + + +def test_lighthouse_is_the_only_built_in_connection(): + assert active_connection() == LIGHTHOUSE + assert LIGHTHOUSE.origin == "https://lighthouse.manipal.edu" + client = LighthouseClient() + assert client.base_url == LIGHTHOUSE.origin + assert not client._read_only_auth + + +@pytest.mark.parametrize("override", [ + Connection("http://sandbox.example", None), + Connection("https://sandbox.example", None), +]) +def test_override_needs_https_and_its_own_cookie_directory(monkeypatch, override): + monkeypatch.setattr(connection, "_override", override) + with pytest.raises(ValueError): + active_connection() + + +def test_override_urls_cookies_and_pagination_are_origin_scoped(sandbox): + client = LighthouseClient() + assert client.canonical_url("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/22985/quizzes/") == "https://sandbox.example/d2l/api/le/1.93/22985/quizzes/" assert client.canonical_url("?page=2", base_url="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/22985/quizzes/").endswith("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/22985/quizzes/?page=2") with pytest.raises(NetworkError): client.get("https://lighthouse.manipal.edu/d2l/api/versions/") with pytest.raises(NetworkError): - client.get("https://hetrynow.brightspace.com.evil.invalid/d2l/api/versions/") + client.get("https://sandbox.example.evil.invalid/d2l/api/versions/") client._apply_cookies_to_session(dict.fromkeys(COOKIE_NAMES, "test")) - assert {cookie.domain for cookie in client._session.cookies} == {"hetrynow.brightspace.com"} - assert client._read_only_auth + assert {cookie.domain for cookie in client._session.cookies} == {"sandbox.example"} + assert client._read_only_auth # never refreshes or migrates auth -def test_trial_does_not_read_production_session(): +def test_override_does_not_read_the_lighthouse_session(sandbox): with patch("lighthouse_cli.api.load_cookies", return_value={}) as load: - client = LighthouseClient(site="trial") + client = LighthouseClient() assert client.cookies == {} kwargs = load.call_args.kwargs - assert kwargs["config_dir"] == connection_for("trial").cookie_dir - assert kwargs["expected_origin"] == connection_for("trial").origin + assert kwargs["config_dir"] == sandbox.cookie_dir + assert kwargs["expected_origin"] == SANDBOX_ORIGIN assert kwargs["read_only"] is True def test_dry_run_does_not_construct_client_or_write(): with patch("lighthouse_cli.assessment_commands.LighthouseClient") as client: - result = CliRunner().invoke(cli, ["instructor", "--site", "trial", "quiz-create", "22985", "--name", "Practice", "--layout", "one-way", "--dry-run", "--json"]) + result = CliRunner().invoke(cli, ["instructor", "quiz-create", "22985", "--name", "Practice", "--layout", "one-way", "--dry-run", "--json"]) assert result.exit_code == 0 assert json.loads(result.stdout)["data"]["PagingTypeId"] == 1 client.assert_not_called() @@ -132,50 +162,56 @@ def test_projection_has_resource_limits(): project([None] * 20001) -def test_session_import_is_sealed_origin_bound_and_separate(): +def test_session_import_is_sealed_and_origin_bound(): document = { - "origin": "https://hetrynow.brightspace.com", + "origin": "https://lighthouse.manipal.edu", "cookies": dict.fromkeys(COOKIE_NAMES, "SYNTHETIC_SESSION"), } - result = CliRunner().invoke( - cli, ["auth", "import-session", "--site", "trial", "--json"], input=json.dumps(document) - ) + result = CliRunner().invoke(cli, ["auth", "import-session", "--json"], input=json.dumps(document)) assert result.exit_code == 0 + assert json.loads(result.stdout) == {"imported": True, "verified": False} assert "SYNTHETIC_SESSION" not in result.output - store = CredentialStore(config_dir=connection_for("trial").cookie_dir) + store = CredentialStore() assert "SYNTHETIC_SESSION" not in store.cookie_file.read_text() - assert not CredentialStore().cookie_file.exists() - assert LighthouseClient(site="trial").cookies == document["cookies"] - store.write_artifact(store.cookie_file, metadata={}, secret={"origin": "https://lighthouse.manipal.edu", "cookies": document["cookies"]}) - assert LighthouseClient(site="trial").cookies == {} + assert LighthouseClient(read_only_auth=True).cookies == document["cookies"] -def test_session_import_rejects_wrong_origin_without_writes(): - document = { - "origin": "https://wrong.invalid", - "cookies": dict.fromkeys(COOKIE_NAMES, "SYNTHETIC_SESSION"), - } - result = CliRunner().invoke( - cli, ["auth", "import-session", "--site", "trial", "--json"], input=json.dumps(document) - ) +@pytest.mark.parametrize("origin", ["https://wrong.invalid", SANDBOX_ORIGIN, "http://lighthouse.manipal.edu"]) +def test_session_import_rejects_another_origin_without_writes(origin): + document = {"origin": origin, "cookies": dict.fromkeys(COOKIE_NAMES, "SYNTHETIC_SESSION")} + result = CliRunner().invoke(cli, ["auth", "import-session", "--json"], input=json.dumps(document)) assert result.exit_code == 1 assert "SYNTHETIC_SESSION" not in result.output - assert not CredentialStore(config_dir=connection_for("trial").cookie_dir).cookie_file.exists() + assert not CredentialStore().cookie_file.exists() + + +def test_session_import_no_longer_accepts_a_site_option(): + result = CliRunner().invoke(cli, ["auth", "import-session", "--site", "lighthouse", "--json"], input="{}") + assert result.exit_code != 0 -def test_trial_artifact_cannot_be_used_from_production_cookie_path(): +def test_foreign_origin_artifact_cannot_be_used_from_the_lighthouse_cookie_path(): store = CredentialStore() store.write_artifact( store.cookie_file, metadata={}, secret={ - "origin": "https://hetrynow.brightspace.com", + "origin": SANDBOX_ORIGIN, "cookies": dict.fromkeys(COOKIE_NAMES, "SYNTHETIC_SESSION"), }, ) assert LighthouseClient(read_only_auth=True).cookies == {} +def test_lighthouse_artifact_cannot_be_used_from_an_override_cookie_path(sandbox): + store = CredentialStore(config_dir=sandbox.cookie_dir) + store.write_artifact(store.cookie_file, metadata={}, secret={ + "origin": "https://lighthouse.manipal.edu", + "cookies": dict.fromkeys(COOKIE_NAMES, "SYNTHETIC_SESSION"), + }) + assert LighthouseClient().cookies == {} + + @pytest.mark.parametrize("submission_type,expected", [("file", 0), ("text", 1)]) def test_assignment_defaults_are_hidden_and_ungraded(submission_type, expected): data = assignment_payload("Practice", "Write an answer", submission_type) @@ -194,7 +230,7 @@ def test_bad_create_input_has_json_error_and_no_side_effects(): def test_role_group_usage_errors_preserve_json_contract(): - result = CliRunner().invoke(cli, ["instructor", "--site", "bogus", "quizzes", "12", "--json"]) + result = CliRunner().invoke(cli, ["instructor", "--bogus", "quizzes", "12", "--json"]) assert result.exit_code == 1 assert json.loads(result.stdout)["error"] diff --git a/tests/test_quiz_attempt_page.py b/tests/test_quiz_attempt_page.py index c2c0f3f..4117a9b 100644 --- a/tests/test_quiz_attempt_page.py +++ b/tests/test_quiz_attempt_page.py @@ -224,7 +224,7 @@ def bootstrap() -> bytes: def test_save_transport_requires_persisted_readback_and_posts_once(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(side_effect=[(bootstrap(), {}), (html(question(1, selected=False, saved="False")), {}), (html(question(1)), {})]) response = Mock(status_code=200) client._request = Mock(return_value=response) @@ -239,7 +239,7 @@ def test_save_transport_requires_persisted_readback_and_posts_once(): def test_http_200_without_persisted_answer_is_unknown_not_success(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) unchanged = html(question(1, selected=False, saved="False")) client.get_raw = Mock(side_effect=[(bootstrap(), {}), (unchanged, {}), (unchanged, {})]) client._request = Mock(return_value=Mock(status_code=200)) @@ -249,7 +249,7 @@ def test_http_200_without_persisted_answer_is_unknown_not_success(): def test_save_readback_auth_expiry_is_unknown_after_post_dispatch(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(side_effect=[(bootstrap(), {}), (html(question(1)), {}), SessionExpiredError("session expired")]) client._request = Mock(return_value=Mock(status_code=200)) with pytest.raises(PreviewSaveUnknownError): @@ -258,7 +258,7 @@ def test_save_readback_auth_expiry_is_unknown_after_post_dispatch(): def test_save_post_auth_expiry_is_unknown_after_dispatch(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(side_effect=[(bootstrap(), {}), (html(question(1)), {})]) client._request = Mock(side_effect=SessionExpiredError("session expired")) with pytest.raises(PreviewSaveUnknownError): @@ -267,7 +267,7 @@ def test_save_post_auth_expiry_is_unknown_after_dispatch(): def test_advance_readback_auth_expiry_is_unknown_after_post_dispatch(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(side_effect=[(bootstrap(), {}), (html(question(1), extra=""), {}), SessionExpiredError("session expired")]) client._request = Mock(return_value=Mock(status_code=200)) with pytest.raises(PreviewAdvanceUnknownError): @@ -276,7 +276,7 @@ def test_advance_readback_auth_expiry_is_unknown_after_post_dispatch(): def test_advance_post_auth_expiry_is_unknown_after_dispatch(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(side_effect=[(bootstrap(), {}), (html(question(1), extra=""), {})]) client._request = Mock(side_effect=SessionExpiredError("session expired")) with pytest.raises(PreviewAdvanceUnknownError): @@ -285,7 +285,7 @@ def test_advance_post_auth_expiry_is_unknown_after_dispatch(): def test_uncertain_post_is_not_replayed_or_echoed(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(side_effect=[(bootstrap(), {}), (html(question(1)), {})]) client._request = Mock(side_effect=RuntimeError("cookie=SESSION_SENTINEL")) with pytest.raises(PreviewSaveUnknownError) as exc: @@ -295,7 +295,7 @@ def test_uncertain_post_is_not_replayed_or_echoed(): def test_start_follows_typed_callback_without_executing_scripts(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) process = '/d2l/lms/quizzing/user/attempt/quiz_start_process_auto.d2l?ou=10&qi=20&isprv=1&fromQB=0&inProgress=0' root = process.replace('quiz_start_process_auto', 'quiz_start_frame_auto') inner = process.replace('quiz_start_process_auto', 'quiz_start_iframe_2_auto') @@ -316,7 +316,7 @@ def test_start_follows_typed_callback_without_executing_scripts(): def test_start_rejects_missing_button_without_creating_attempt(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(return_value=(b'

Quiz Summary

', {})) client._request = Mock() with pytest.raises(PreviewRefusedError, match="--bypass-availability"): @@ -326,7 +326,7 @@ def test_start_rejects_missing_button_without_creating_attempt(): def start_client(readback): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) process = '/d2l/lms/quizzing/user/attempt/quiz_start_process_auto.d2l?ou=10&qi=20&isprv=1&fromQB=0&inProgress=0' root = process.replace('quiz_start_process_auto', 'quiz_start_frame_auto') inner = process.replace('quiz_start_process_auto', 'quiz_start_iframe_2_auto') @@ -363,7 +363,7 @@ def test_start_identity_callback_failure_is_unknown_with_identity(): def test_server_current_page_is_used_only_for_the_same_preview_attempt(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(return_value=(html(question(2, page=2), page=2), {})) result = read_server_current_preview(client, course_id=10, quiz_id=20, attempt_id=30, page=1) assert result.page == 2 and result.questions[0]["question_id"] == 102 @@ -376,14 +376,14 @@ def test_server_current_page_rejects_another_attempt_or_a_learner_page(field, va original = {"ai": "30", "isprv": "1", "qi": "20"}[field] body = body.replace(f'name="{field}" type="hidden" value="{original}"'.encode(), f'name="{field}" type="hidden" value="{value}"'.encode()) - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(return_value=(body, {})) with pytest.raises(PreviewPageError): read_server_current_preview(client, course_id=10, quiz_id=20, attempt_id=30, page=1) def test_start_readback_auth_expiry_is_unknown_after_state_creation(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) process = '/d2l/lms/quizzing/user/attempt/quiz_start_process_auto.d2l?ou=10&qi=20&isprv=1&fromQB=0&inProgress=0' root = process.replace('quiz_start_process_auto', 'quiz_start_frame_auto') inner = process.replace('quiz_start_process_auto', 'quiz_start_iframe_2_auto') @@ -403,7 +403,7 @@ def test_start_readback_auth_expiry_is_unknown_after_state_creation(): def test_start_process_auth_expiry_is_unknown_after_dispatch(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) process = '/d2l/lms/quizzing/user/attempt/quiz_start_process_auto.d2l?ou=10&qi=20&isprv=1&fromQB=0&inProgress=0' root = process.replace('quiz_start_process_auto', 'quiz_start_frame_auto') inner = process.replace('quiz_start_process_auto', 'quiz_start_iframe_2_auto') @@ -420,7 +420,7 @@ def test_start_process_auth_expiry_is_unknown_after_dispatch(): def test_start_summary_post_auth_expiry_is_unknown_after_dispatch(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(return_value=(html("", extra="") + bootstrap(), {})) client._request = Mock(side_effect=SessionExpiredError("session expired")) with pytest.raises(PreviewStartUnknownError): @@ -428,7 +428,7 @@ def test_start_summary_post_auth_expiry_is_unknown_after_dispatch(): client.get_raw.assert_called_once() client._request.assert_called_once() def test_ambiguous_start_does_not_retry_or_trust_script_strings(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) process = '/d2l/lms/quizzing/user/attempt/quiz_start_process_auto.d2l?ou=10&qi=20&isprv=1&fromQB=0&inProgress=0' root = process.replace('quiz_start_process_auto', 'quiz_start_frame_auto') inner = process.replace('quiz_start_process_auto', 'quiz_start_iframe_2_auto') diff --git a/tests/test_quiz_preview_finish.py b/tests/test_quiz_preview_finish.py index 35c4d35..4b65173 100644 --- a/tests/test_quiz_preview_finish.py +++ b/tests/test_quiz_preview_finish.py @@ -18,7 +18,7 @@ def client_for_submit(*, rpc_result: str | None = None, secure_browser: str = "0"): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) confirmation = f'''
@@ -87,7 +87,7 @@ def test_wrong_identity_mode_or_extra_script_is_not_executed_or_accepted(result) def test_success_heading_without_completed_attempt_record_is_not_a_receipt(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(return_value=(b'

Your work has been saved and submitted

', {})) client.get_json = Mock(return_value={"AttemptId": 30, "QuizId": 20, "UserId": 7, "Completed": None}) with pytest.raises(PreviewSubmitUnknownError): @@ -95,7 +95,7 @@ def test_success_heading_without_completed_attempt_record_is_not_a_receipt(): def test_completed_attempt_record_verifies_localized_receipt_heading(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(return_value=(b"

Arbeit gespeichert

", {})) client.get_json = Mock(return_value={"AttemptId": 30, "QuizId": 20, "UserId": 7, "Completed": "2026-09-17T15:00:00Z", "Score": 1}) result = verify_receipt(client, course_id=10, quiz_id=20, attempt_id=30, actor_id=7) @@ -104,7 +104,7 @@ def test_completed_attempt_record_verifies_localized_receipt_heading(): def test_receipt_session_expiry_is_not_masked_as_unknown_submission(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(side_effect=SessionExpiredError("session expired")) with pytest.raises(SessionExpiredError): verify_receipt(client, course_id=10, quiz_id=20, attempt_id=30, actor_id=7) diff --git a/tests/test_quiz_preview_session.py b/tests/test_quiz_preview_session.py index a56378f..c37a764 100644 --- a/tests/test_quiz_preview_session.py +++ b/tests/test_quiz_preview_session.py @@ -42,9 +42,9 @@ def _attempt(attempt_id: int, *, quiz_id: int = 20, actor_id: int = 7, completed @pytest.fixture def remote(): client = Mock() - client.base_url = "https://hetrynow.brightspace.com" + client.base_url = "https://lighthouse.manipal.edu" state = {"actor": 7, "completed": None, "attempts": [], "records": {}, "listing": None} - client.canonical_url.side_effect = LighthouseClient(site="trial").canonical_url + client.canonical_url.side_effect = LighthouseClient(read_only_auth=True).canonical_url def read(path, **kwargs): if path.endswith("users/whoami"): return {"Identifier": state["actor"]} @@ -71,7 +71,7 @@ def start_local(workflow): def test_one_active_preview_per_quiz_and_sealed_cursor(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) start_local(workflow) assert workflow.status()["status"] == "active" raw = workflow.path.read_text() @@ -100,7 +100,7 @@ def saved(workflow): def test_start_seals_account_bound_baseline_before_dispatch(remote): _, state = remote state["attempts"] = [_attempt(5), _attempt(6, completed="2026-09-01T00:00:00Z"), _attempt(9, actor_id=8)] - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) seen = {} def fake_start(client, **kwargs): seen.update(saved(workflow)) @@ -122,7 +122,7 @@ def test_start_is_refused_before_dispatch_when_listing_fails(remote): def fail(path): raise NetworkError("listing failed") state["listing"] = fail - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) with patch("lighthouse_cli.quiz_preview_session.start_preview") as start: with pytest.raises(PreviewWorkflowError, match="nothing was started"): workflow.run("start") @@ -131,7 +131,7 @@ def fail(path): def test_identity_is_sealed_before_page_readback(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=(31, 1)) status = workflow.status() assert (status["status"], status["attempt_id"], status["page"]) == ("uncertain", 31, 1) @@ -139,7 +139,7 @@ def test_identity_is_sealed_before_page_readback(remote): def test_identity_survives_an_interrupted_start_after_it_is_sealed(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) def interrupted(client, *, on_identity=None, **kwargs): on_identity(31, 1) raise KeyboardInterrupt # e.g. the process is stopped during readback @@ -150,7 +150,7 @@ def interrupted(client, *, on_identity=None, **kwargs): def test_exception_carried_identity_is_sealed_for_page_recovery(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) with patch("lighthouse_cli.quiz_preview_session.start_preview", side_effect=PreviewStartUnknownError(attempt_id=30, page=1)): with pytest.raises(PreviewStartUnknownError): @@ -164,7 +164,7 @@ def test_exception_carried_identity_is_sealed_for_page_recovery(remote): def test_unknown_start_without_identity_never_binds_from_listing(remote): client, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) state["attempts"] = [] unknown_start(workflow) state["attempts"] = [_attempt(31)] @@ -175,7 +175,7 @@ def test_unknown_start_without_identity_never_binds_from_listing(remote): @pytest.mark.parametrize("identity", [None, (31, 1)]) def test_unresolved_start_never_allows_a_fresh_start(remote, identity): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=identity) with patch("lighthouse_cli.quiz_preview_session.start_preview") as start: with pytest.raises(PreviewWorkflowError, match="reconcile"): @@ -185,7 +185,7 @@ def test_unresolved_start_never_allows_a_fresh_start(remote, identity): @pytest.mark.parametrize("identity", [None, (31, 1)]) def test_abandon_is_local_and_keeps_the_unresolved_start_guard(remote, identity): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=identity) for _ in range(2): # repeated abandonment keeps the guard result = workflow.abandon() @@ -197,7 +197,7 @@ def test_abandon_is_local_and_keeps_the_unresolved_start_guard(remote, identity) def test_abandoning_a_verified_preview_still_allows_a_new_start(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) start_local(workflow) assert workflow.abandon()["unresolved_start"] is False start_local(workflow) @@ -206,7 +206,7 @@ def test_abandoning_a_verified_preview_still_allows_a_new_start(remote): def test_reconcile_without_attempt_id_only_lists_candidates(remote): _, state = remote state["attempts"] = [_attempt(5)] - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) before = saved(workflow) state["attempts"] = [ @@ -223,7 +223,7 @@ def test_reconcile_without_attempt_id_only_lists_candidates(remote): def test_reconcile_with_an_empty_listing_stays_uncertain(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) result = workflow.reconcile() assert result["candidates"] == [] @@ -238,7 +238,7 @@ def test_reconcile_with_an_empty_listing_stays_uncertain(remote): def test_reconcile_refuses_an_attempt_that_is_not_a_candidate(remote, attempt_id): _, state = remote state["attempts"] = [_attempt(5)] - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) before = saved(workflow) state["attempts"] = [_attempt(5), _attempt(33, actor_id=8)] @@ -252,7 +252,7 @@ def test_reconcile_refuses_an_attempt_that_is_not_a_candidate(remote, attempt_id @pytest.mark.parametrize("layout_page", [1, 2]) def test_reconcile_binds_a_chosen_candidate_at_the_server_page(remote, layout_page): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) workflow.abandon() state["attempts"] = [_attempt(31)] @@ -269,7 +269,7 @@ def test_reconcile_binds_a_chosen_candidate_at_the_server_page(remote, layout_pa def test_reconcile_keeps_a_bound_identity_and_cursor(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=(31, 1)) before = saved(workflow) with pytest.raises(PreviewWorkflowError, match="different attempt"): @@ -286,7 +286,7 @@ def test_reconcile_keeps_a_bound_identity_and_cursor(remote): def test_reconcile_keeps_a_bound_cursor_beyond_page_one(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=(31, 2)) with patch("lighthouse_cli.quiz_preview_session.read_current_preview", return_value=page(2, attempt_id=31)) as strict, \ @@ -299,7 +299,7 @@ def test_reconcile_keeps_a_bound_cursor_beyond_page_one(remote): def test_reconcile_refuses_an_unbound_candidate_whose_record_is_completed(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) before = saved(workflow) state["attempts"] = [_attempt(31)] # listed as incomplete... @@ -314,7 +314,7 @@ def test_reconcile_refuses_an_unbound_candidate_whose_record_is_completed(remote def test_reconcile_refuses_an_unbound_candidate_on_an_unsupported_layout(remote): client, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) before = saved(workflow) state["attempts"] = [_attempt(31)] @@ -332,11 +332,11 @@ def test_reconcile_refuses_an_unbound_candidate_on_an_unsupported_layout(remote) lambda first: first.replace("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/quizzes/20/", "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/quizzes/99/") + "?bookmark=x", lambda first: first + "?bookmark=x&extra=1", lambda first: first + "?page=2", - lambda first: first.replace("https://hetrynow.brightspace.com", "https://evil.test") + "?bookmark=x", + lambda first: first.replace("https://lighthouse.manipal.edu", "https://evil.test") + "?bookmark=x", ]) def test_attempt_paging_never_leaves_the_attempts_route(remote, make_next): client, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) requested = [] def listing(path): @@ -360,7 +360,7 @@ def listing(path): return many[5000:] # a wrapped page followed by a plain list return {"Objects": many[:5000], "Next": path + "?bookmark=abc"} state["listing"] = listing - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) with patch("lighthouse_cli.quiz_preview_session.start_preview") as start: with pytest.raises(PreviewWorkflowError, match="Too many attempts"): workflow.run("start") @@ -370,7 +370,7 @@ def listing(path): def test_attempt_paging_follows_bookmarks_on_the_same_route(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) def listing(path): if "bookmark=" in path: @@ -382,7 +382,7 @@ def listing(path): def test_candidate_output_never_echoes_server_text(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) workflow.store.write_artifact(workflow.path, metadata={}, secret={ "version": 1, "origin": workflow.connection.origin, "mode": "preview", "actor_id": 7, "course_id": 10, "quiz_id": 20, "status": "uncertain", "operation": "start", @@ -399,7 +399,7 @@ def test_candidate_output_never_echoes_server_text(remote): def test_legacy_abandoned_start_without_identity_stays_guarded(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) workflow.store.write_artifact(workflow.path, metadata={}, secret={ "version": 1, "origin": workflow.connection.origin, "mode": "preview", "actor_id": 7, "course_id": 10, "quiz_id": 20, "status": "abandoned", "operation": None, @@ -416,7 +416,7 @@ def test_legacy_abandoned_start_without_identity_stays_guarded(remote): def test_confirmed_no_remote_attempt_releases_the_guard_only_without_candidates(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) state["attempts"] = [_attempt(31)] with pytest.raises(PreviewWorkflowError, match="Candidate attempts exist"): @@ -430,7 +430,7 @@ def test_confirmed_no_remote_attempt_releases_the_guard_only_without_candidates( def test_confirmed_no_remote_attempt_is_refused_for_a_bound_start(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=(31, 1)) with pytest.raises(PreviewWorkflowError, match="bound to a known attempt"): workflow.reconcile(confirm_no_remote_attempt=True) @@ -440,7 +440,7 @@ def test_confirmed_no_remote_attempt_is_refused_for_a_bound_start(remote): def test_reconcile_resumes_a_bound_start_advanced_in_the_browser(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=(31, 1)) with patch("lighthouse_cli.quiz_preview_session.read_current_preview", side_effect=PreviewPageError()), \ patch("lighthouse_cli.quiz_preview_session.read_server_current_preview", @@ -452,7 +452,7 @@ def test_reconcile_resumes_a_bound_start_advanced_in_the_browser(remote): def test_bound_start_fallback_is_refused_on_an_unsupported_layout(remote): client, _ = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=(31, 1)) before = saved(workflow) client.get_quiz_detail.return_value = {"PagingTypeId": 1, "PreventMovingBackwards": False, @@ -472,14 +472,14 @@ def test_bound_start_fallback_is_refused_on_an_unsupported_layout(remote): def test_local_cursor_refusals_come_before_authentication(operation, message): with patch("lighthouse_cli.quiz_preview_session.LighthouseClient", side_effect=AssertionError("must not authenticate")) as client: - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) with pytest.raises(PreviewWorkflowError, match=message): workflow.run(operation, question_id=1, choice_id=2) client.assert_not_called() def test_unresolved_start_blocks_start_before_authentication(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) with patch("lighthouse_cli.quiz_preview_session.LighthouseClient", side_effect=AssertionError("must not authenticate")) as client: @@ -492,7 +492,7 @@ def test_unresolved_start_blocks_start_before_authentication(remote): def test_reconcile_of_a_completed_bound_attempt_verifies_the_receipt(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=(31, 1)) state["records"][31] = _attempt(31, completed="2999-01-01T00:05:00Z") receipt = {"submitted": True, "receipt_verified": True, "attempt_id": 31} @@ -505,7 +505,7 @@ def test_reconcile_of_a_completed_bound_attempt_verifies_the_receipt(remote): @pytest.mark.parametrize("record", [_attempt(31, actor_id=8), _attempt(31, quiz_id=99)]) def test_reconcile_identity_mismatch_leaves_the_checkpoint_unchanged(remote, record): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) before = saved(workflow) state["attempts"] = [_attempt(31)] @@ -517,7 +517,7 @@ def test_reconcile_identity_mismatch_leaves_the_checkpoint_unchanged(remote, rec def test_reconcile_unverified_preview_page_leaves_the_checkpoint_unchanged(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) before = saved(workflow) state["attempts"] = [_attempt(31)] @@ -530,7 +530,7 @@ def test_reconcile_unverified_preview_page_leaves_the_checkpoint_unchanged(remot def test_reconcile_listing_failure_leaves_the_checkpoint_unchanged(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) before = saved(workflow) def forbidden(path): @@ -543,7 +543,7 @@ def forbidden(path): def test_reconcile_refuses_another_signed_in_account(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow, identity=(31, 1)) state["actor"] = 8 with pytest.raises(PreviewWorkflowError, match="different signed-in account"): @@ -551,14 +551,14 @@ def test_reconcile_refuses_another_signed_in_account(remote): def test_reconcile_refuses_a_resolved_checkpoint(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) start_local(workflow) with pytest.raises(PreviewWorkflowError, match="unresolved start"): workflow.reconcile() def test_reconcile_respects_the_single_writer_lock(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) unknown_start(workflow) with workflow._locked(), pytest.raises(PreviewWorkflowError, match="Another operation"): workflow.reconcile() @@ -566,7 +566,7 @@ def test_reconcile_respects_the_single_writer_lock(remote): def test_legacy_checkpoint_without_baseline_requires_explicit_selection(remote): _, state = remote - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) workflow.store.write_artifact(workflow.path, metadata={}, secret={ "version": 1, "origin": workflow.connection.origin, "mode": "preview", "actor_id": 7, "course_id": 10, "quiz_id": 20, "status": "uncertain", "operation": "start", @@ -588,7 +588,7 @@ def test_legacy_checkpoint_without_baseline_requires_explicit_selection(remote): {"start_intent_at": "yesterday"}, ]) def test_invalid_optional_checkpoint_fields_fail_closed(remote, extra): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) workflow.store.write_artifact(workflow.path, metadata={}, secret={ "version": 1, "origin": workflow.connection.origin, "mode": "preview", "actor_id": 7, "course_id": 10, "quiz_id": 20, "status": "uncertain", "operation": "start", @@ -599,7 +599,7 @@ def test_invalid_optional_checkpoint_fields_fail_closed(remote, extra): def test_changed_account_cannot_mutate_saved_attempt(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) start_local(workflow) remote[1]["actor"] = 8 with patch("lighthouse_cli.quiz_preview_session.advance_current_preview") as advance: @@ -609,7 +609,7 @@ def test_changed_account_cannot_mutate_saved_attempt(remote): def test_uncertain_save_blocks_writes_and_recovers_by_readback(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) start_local(workflow) with patch("lighthouse_cli.quiz_preview_session.save_current_preview_answer", side_effect=PreviewSaveUnknownError()) as save: with pytest.raises(PreviewSaveUnknownError): @@ -625,7 +625,7 @@ def test_uncertain_save_blocks_writes_and_recovers_by_readback(remote): def test_uncertain_advance_stays_blocked_without_authoritative_cursor(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) start_local(workflow) with patch("lighthouse_cli.quiz_preview_session.advance_current_preview", side_effect=PreviewAdvanceUnknownError()) as advance: with pytest.raises(PreviewAdvanceUnknownError): @@ -638,7 +638,7 @@ def test_uncertain_advance_stays_blocked_without_authoritative_cursor(remote): def test_commit_failure_after_advance_never_restores_old_active_cursor(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) start_local(workflow) original = workflow.store.write_artifact calls = 0 @@ -660,7 +660,7 @@ def fail_commit(*args, **kwargs): def test_completed_remote_attempt_is_not_submitted_again(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) start_local(workflow) remote[1]["completed"] = "2026-09-17T15:00:00Z" receipt = {"submitted": True, "receipt_verified": True, "attempt_id": 30} @@ -672,23 +672,30 @@ def test_completed_remote_attempt_is_not_submitted_again(remote): def test_lock_contention_fails_without_waiting(remote): - workflow = PreviewWorkflow("trial", 10, 20) + workflow = PreviewWorkflow(10, 20) with workflow._locked(): with pytest.raises(PreviewWorkflowError, match="Another operation"): workflow.status() -def test_production_site_is_not_enabled_by_the_prototype(): - with pytest.raises(PreviewWorkflowError, match="requires --site trial"): - PreviewWorkflow("lighthouse", 10, 20) +def test_preview_checkpoint_is_bound_to_the_lighthouse_origin(remote): + workflow = PreviewWorkflow(10, 20) + assert workflow.connection.origin == "https://lighthouse.manipal.edu" + workflow.store.write_artifact(workflow.path, metadata={}, secret={ + "version": 1, "origin": "https://sandbox.example", "mode": "preview", "actor_id": 7, + "course_id": 10, "quiz_id": 20, "status": "active", "operation": None, + "attempt_id": 30, "page": 1, + }) + with pytest.raises(PreviewWorkflowError, match="invalid"): + workflow.status() def test_cli_dry_run_and_declined_write_do_not_open_credentials(): with patch("lighthouse_cli.quiz_preview_commands.PreviewWorkflow") as workflow: - result = CliRunner().invoke(cli, ["instructor", "--site", "trial", "preview", "start", "10", "20", "--dry-run", "--json"]) + result = CliRunner().invoke(cli, ["instructor", "preview", "start", "10", "20", "--dry-run", "--json"]) assert result.exit_code == 0 assert json.loads(result.stdout)["dry_run"] is True - declined = CliRunner().invoke(cli, ["instructor", "--site", "trial", "preview", "start", "10", "20", "--json"]) + declined = CliRunner().invoke(cli, ["instructor", "preview", "start", "10", "20", "--json"]) assert declined.exit_code == 1 assert json.loads(declined.stdout) == {"cancelled": True} workflow.assert_not_called() @@ -696,7 +703,7 @@ def test_cli_dry_run_and_declined_write_do_not_open_credentials(): def test_cli_error_is_json_only_and_secret_safe(): with patch("lighthouse_cli.quiz_preview_commands.PreviewWorkflow", side_effect=RuntimeError("cookie=SECRET_SENTINEL")): - result = CliRunner().invoke(cli, ["instructor", "--site", "trial", "preview", "page", "10", "20", "--json"]) + result = CliRunner().invoke(cli, ["instructor", "preview", "page", "10", "20", "--json"]) assert result.exit_code == 1 assert json.loads(result.stdout)["error"] assert "SECRET_SENTINEL" not in result.stdout + result.stderr @@ -709,7 +716,7 @@ def test_cli_reconcile_is_read_only_and_keeps_json_on_stdout(): } result = CliRunner().invoke( cli, - ["instructor", "--site", "trial", "preview", "reconcile", "10", "20", "--json"], + ["instructor", "preview", "reconcile", "10", "20", "--json"], ) assert result.exit_code == 0 assert json.loads(result.stdout)["candidates"] == [] @@ -723,7 +730,7 @@ def test_cli_shows_fixed_refusal_messages_verbatim(): workflow.return_value.run.side_effect = PreviewRefusedError(REFUSE_NOT_ON_PAGE) result = CliRunner().invoke( cli, - ["instructor", "--site", "trial", "preview", "answer", "10", "20", "1", "2", "--yes", "--json"], + ["instructor", "preview", "answer", "10", "20", "1", "2", "--yes", "--json"], ) assert result.exit_code == 1 assert json.loads(result.stdout)["error"] == REFUSE_NOT_ON_PAGE diff --git a/tests/test_request_protection.py b/tests/test_request_protection.py index 389d35d..b28b126 100644 --- a/tests/test_request_protection.py +++ b/tests/test_request_protection.py @@ -23,7 +23,7 @@ def test_bad_bootstrap_fails_without_echoing_content(body): def test_bootstrap_cached_for_same_client(): - client = LighthouseClient(site="trial") + client = LighthouseClient(read_only_auth=True) client.get_raw = Mock(return_value=(b"", {})) assert client.get_csrf_token() == "synthetic-csrf" assert client.get_csrf_token() == "synthetic-csrf" From 8fdca9c53168a53776b810ed329ff33fbb8380fc Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:28:33 +0530 Subject: [PATCH 2/2] fix: tighten the private connection override From Kilo and MiMo reviews of 6b3a1d8: - Accept only a plain https://host override origin (no credentials, port, path, query, fragment or non-canonical case). - Grant auth refresh/migration only to the built-in Lighthouse connection object; any override, even one equal in value to Lighthouse, is read-only. - README: drop the leftover 'Both default to Lighthouse.' --- .secrets.baseline | 4 ++-- README.md | 2 +- lighthouse_cli/api.py | 5 +++-- lighthouse_cli/connection.py | 11 +++++++++-- tests/test_assessment_workflows.py | 18 ++++++++++++++++++ 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 24557aa..0e08b6b 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -179,7 +179,7 @@ "filename": "tests/test_assessment_workflows.py", "hashed_secret": "f714a8256826794cf5e9c3aa49c17d9ea53b8bd5", "is_verified": false, - "line_number": 132, + "line_number": 150, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-09-24T11:36:09Z" + "generated_at": "2026-09-24T11:57:21Z" } diff --git a/README.md b/README.md index 5d2016d..95ea721 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ load credentials, make requests, or write local files. ### Student and instructor course tools The `student` and `instructor` groups add role-oriented views without changing -your account's permissions. Both default to Lighthouse. Use numeric course and +your account's permissions. Use numeric course and resource IDs; the existing top-level commands continue to accept course names. ```bash diff --git a/lighthouse_cli/api.py b/lighthouse_cli/api.py index 9a0859c..8a7b0ff 100644 --- a/lighthouse_cli/api.py +++ b/lighthouse_cli/api.py @@ -410,8 +410,9 @@ def __init__(self, read_only_auth: bool = False) -> None: self._loaded = False self._cache: dict[str, Any] = {} self._csrf_token: str | None = None - # Only the real Lighthouse connection may refresh or migrate auth. - self._read_only_auth = bool(read_only_auth) or self.connection != LIGHTHOUSE + # Only the built-in Lighthouse connection object may refresh or + # migrate auth; any override is read-only, even one naming Lighthouse. + self._read_only_auth = bool(read_only_auth) or self.connection is not LIGHTHOUSE # -- cookie management -------------------------------------------------- diff --git a/lighthouse_cli/connection.py b/lighthouse_cli/connection.py index 32fabe0..b8e4199 100644 --- a/lighthouse_cli/connection.py +++ b/lighthouse_cli/connection.py @@ -29,11 +29,18 @@ def api_le(self) -> str: _override: Connection | None = None +def _is_plain_https_origin(origin: str) -> bool: + """``https://host`` exactly: no credentials, port, path, query or fragment.""" + parts = urlsplit(origin) + return (parts.scheme == "https" and bool(parts.hostname) and parts.port is None + and "@" not in parts.netloc and origin == f"https://{parts.hostname}") + + def active_connection() -> Connection: """Return the connection for this process: Lighthouse unless overridden.""" override = _override if override is None: return LIGHTHOUSE - if not override.origin.startswith("https://") or override.cookie_dir is None: - raise ValueError("An overriding connection needs an HTTPS origin and its own cookie directory.") + if not _is_plain_https_origin(override.origin) or override.cookie_dir is None: + raise ValueError("An overriding connection needs a plain HTTPS origin and its own cookie directory.") return override diff --git a/tests/test_assessment_workflows.py b/tests/test_assessment_workflows.py index e68e5ee..950377e 100644 --- a/tests/test_assessment_workflows.py +++ b/tests/test_assessment_workflows.py @@ -5,6 +5,7 @@ import json import subprocess import sys +from pathlib import Path from unittest.mock import Mock, patch import pytest @@ -68,6 +69,11 @@ def test_lighthouse_is_the_only_built_in_connection(): @pytest.mark.parametrize("override", [ Connection("http://sandbox.example", None), Connection("https://sandbox.example", None), + Connection("https://", Path("/sandbox")), + Connection("https://sandbox.example/extra/path", Path("/sandbox")), + Connection("https://user@sandbox.example", Path("/sandbox")), + Connection("https://sandbox.example:8443", Path("/sandbox")), + Connection("https://Sandbox.Example", Path("/sandbox")), ]) def test_override_needs_https_and_its_own_cookie_directory(monkeypatch, override): monkeypatch.setattr(connection, "_override", override) @@ -75,6 +81,18 @@ def test_override_needs_https_and_its_own_cookie_directory(monkeypatch, override active_connection() +def test_only_the_built_in_lighthouse_object_may_refresh_auth(monkeypatch, tmp_path): + # Even a value-identical copy of the Lighthouse connection (which the + # override validation cannot produce today) must not gain refresh rights. + copy = Connection(LIGHTHOUSE.origin, None) + assert copy == LIGHTHOUSE and copy is not LIGHTHOUSE + monkeypatch.setattr(connection, "active_connection", lambda: copy) + assert LighthouseClient()._read_only_auth + monkeypatch.setattr(connection, "_override", Connection(LIGHTHOUSE.origin, tmp_path / "copy")) + monkeypatch.setattr(connection, "active_connection", active_connection) + assert LighthouseClient()._read_only_auth + + def test_override_urls_cookies_and_pagination_are_origin_scoped(sandbox): client = LighthouseClient() assert client.canonical_url("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/22985/quizzes/") == "https://sandbox.example/d2l/api/le/1.93/22985/quizzes/"