#!/bin/bash # Wonder Ink's temporary workspace restore: which tabs come back, and when. # # lib/classroom_wonderink_workspace.py decides, from a file and the clock, # whether a teacher's recent tabs are opened again, and which of them may # be. Every way that could go wrong is either the next teacher meeting the # last one's tabs, a credential written to disk, or a file Wonder Ink was # never meant to open — so each is tested here with a clock the test # controls. Whether the engine really restores them is # tools/lab/wonderink-workspace-check's question. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' import base64 import json import os import stat import sys from pathlib import Path import classroom_wonderink_workspace as ws root = Path(sys.argv[1]) work = Path(os.environ["WORK"]) passed = failed = 0 def check(label, condition, detail=""): global passed, failed if condition: print(f" ok {label}") passed += 1 else: print(f" FAIL {label}") if detail: print(f" {detail}") failed += 1 HOUR = 60 * 60 NOW = 1_790_000_000.0 downloads = work / "Downloads" / "Wonder Ink" downloads.mkdir(parents=True) sheet = downloads / "Colour the Ark.pdf" sheet.write_bytes(b"%PDF-1.4 worksheet") path = work / "wonderink-workspace.json" SITE = "https://app.wonderink.org/?" SLIDE = "https://dcc-sbbd-user-content.s3.amazonaws.com/5ee6/69fd-SaulWelcomeQuestionSlide.png" VIDEO = "https://player.vimeo.com/video/1144568454" THREE = [ws.tab("web", SITE, "Welcome Question"), ws.tab("web", SLIDE, "Slide"), ws.tab("web", VIDEO, "Video")] print("1. how long") check("two hours by default", ws.idle_limit({}) == 2 * HOUR) check("the test override shortens it", ws.idle_limit({ws.TEST_OVERRIDE: "120"}) == 120) check("but never below ten seconds", ws.idle_limit({ws.TEST_OVERRIDE: "1"}) == ws.TEST_SHORTEST) check("and never lengthens it", ws.idle_limit({ws.TEST_OVERRIDE: "999999"}) == 2 * HOUR) check("nonsense in it is the default", ws.idle_limit({ws.TEST_OVERRIDE: "soon"}) == 2 * HOUR) check("and it says it is one", ws.is_test_limit({ws.TEST_OVERRIDE: "120"}) and not ws.is_test_limit({})) print() print("2. A. restore within the window") def keep(tabs=THREE, active=1, last=NOW, attempts=0): ws.write(path, tabs, active, last, attempts) for label, minutes in (("five minutes", 5), ("ninety minutes", 90), ("a second before two hours", 120 - 1 / 60)): keep() data, why = ws.read(path, now=NOW + minutes * 60) check(f"{label} later: restored", data is not None and len(data["tabs"]) == 3, why) check("with the time of the last use, not the time it was opened again", data["last_activity"] == NOW) print() print("3. B. expiration") for label, seconds in (("two hours and a minute", 2 * HOUR + 60), ("the next day", 24 * HOUR), ("the next week", 7 * 24 * HOUR)): keep() data, why = ws.read(path, now=NOW + seconds) check(f"{label} later: fresh ({why})", data is None and why == "expired") check("and the file is gone", not path.exists()) keep(last=NOW) data, why = ws.read(path, now=NOW + 30, limit=20) check("the test override applies to a kept workspace", data is None and why == "expired", why) keep(last=NOW) data, why = ws.read(path, now=NOW - 2 * HOUR) check("a clock gone back two hours: fresh, not a workspace made young", data is None and "clock" in why, why) keep(last=NOW) data, why = ws.read(path, now=NOW - 30) check("a clock corrected by half a minute: still restored", data is not None, why) print() print("4. D. which tabs, in which order, which in front") keep() data, _ = ws.read(path, now=NOW + 60) check("the order is kept", [t["where"] for t in data["tabs"]] == [SITE, SLIDE, VIDEO]) check("and the tab in front", data["active"] == 1) check("and readable titles", [t["title"] for t in data["tabs"]] == ["Welcome Question", "Slide", "Video"]) tabs, active = ws.plan(THREE, 1, downloads) check("all three can be opened, the middle one in front", len(tabs) == 3 and active == 1) five_site = [ws.tab("web", SITE, f"Lesson {n}") for n in range(3)] + [ws.tab("web", SLIDE, "Slide"), ws.tab("web", "https://app.wonderink.org/", "Home")] tabs, active = ws.plan(five_site, 4, downloads) check("WonderInk's own tabs, which share one address, become one sign-in page, not four", [t["where"] for t in tabs] == [SITE, SLIDE], tabs) check("and a WonderInk tab in front brings its one tab to the front", active == 0) tabs, active = ws.plan(five_site, 3, downloads) check("the slide in front stays in front", active == 1) with_sheet = [ws.tab("web", SITE, "Lesson"), ws.tab("worksheet", str(sheet), "Colour the Ark"), ws.tab("web", SLIDE, "Slide")] tabs, active = ws.plan(with_sheet, 1, downloads) check("a worksheet that still exists comes back, where it was and named", tabs[1] == ws.tab("worksheet", str(sheet), "Colour the Ark") and active == 1, tabs) gone = downloads / "Gone.pdf" tabs, active = ws.plan([ws.tab("web", SITE), ws.tab("worksheet", str(gone), "Gone"), ws.tab("web", SLIDE)], 1, downloads) check("a worksheet whose file has gone is left out, silently", [t["where"] for t in tabs] == [SITE, SLIDE], tabs) check("and the tab in front becomes the one before it", active == 0) print() print("5. only files Wonder Ink downloaded, and only PDFs") elsewhere = work / "secret.pdf" elsewhere.write_bytes(b"%PDF-1.4 not a worksheet") (downloads / "sub").mkdir() (downloads / "sub" / "deep.pdf").write_bytes(b"%PDF-1.4") notpdf = downloads / "notes.pdf" notpdf.write_text("an error page") link = downloads / "link.pdf" link.symlink_to(elsewhere) for label, target in (("a file outside Downloads/Wonder Ink", elsewhere), ("a system file", Path("/etc/passwd")), ("a file in a folder inside it", downloads / "sub" / "deep.pdf"), ("a file that is not a PDF", notpdf), ("a link out of it", link), ("a path climbing out of it", downloads / ".." / ".." / "secret.pdf")): tabs, _ = ws.plan([ws.tab("worksheet", str(target))], 0, downloads) check(f"{label}: never opened", tabs == [], tabs) print() print("6. only http and https, and no credentials") for label, url in (("a file: address", "file:///etc/passwd"), ("javascript:", "javascript:alert(1)"), ("data:", "data:text/html,hi"), ("blob:", "blob:https://app.wonderink.org/abc"), ("chrome:", "chrome://settings"), ("about:blank", "about:blank"), ("a user and password in it", "https://teacher:secret@app.wonderink.org/"), ("a token", "https://example.org/v?token=abc"), ("an access_token", "https://example.org/v?access_token=abc"), ("a password", "https://example.org/?password=abc"), ("an S3 signature", "https://b.s3.amazonaws.com/x.png?X-Amz-Signature=abc"), ("a key", "https://example.org/?api_key=abc"), ("a session", "https://example.org/?sessionid=abc")): check(f"{label}: not kept", not ws.keepable_url(url)) check("an ordinary lesson resource is", ws.keepable_url(SLIDE) and ws.keepable_url(VIDEO + "?h=91f1770ec7")) print() print("7. F. privacy: what is on disk") basic = "Basic " + base64.b64encode(b"teacher@example.org:hunter2hunter2").decode() risky = THREE + [ws.tab("web", "https://teacher:hunter2hunter2@app.wonderink.org/", "creds"), ws.tab("web", "https://example.org/?token=" + basic, "token")] ws.write(path, risky, 0, NOW) text = path.read_text() data = json.loads(text) check("only what may be opened again is written", len(data["tabs"]) == 3, data["tabs"]) check("no password, Basic-auth credential or token in the file", "hunter2" not in text and "Basic " not in text and "token" not in text.lower() and "@" not in text, text) check("no cookie, header or storage of any kind", not any(k in text.lower() for k in ("cookie", "rrf-auth", "x-rrf", "authorization", "localstorage", "sessionstorage"))) check("exactly these fields, and no others", set(data) == {"format", "last_activity", "active", "attempts", "tabs"} and all(set(t) == {"kind", "where", "title"} for t in data["tabs"]), data) check("readable by the classroom account only", stat.S_IMODE(path.stat().st_mode) == 0o600) check("no half-written file is left beside it", not path.with_name(path.name + ".new").exists()) check("a workspace nobody has used (no time of last use) is not written", not ws.write(path, THREE, 0, None) and not path.exists()) check("nor one with nothing to open", not ws.write(path, [ws.tab("web", "about:blank")], 0, NOW) and not path.exists()) print() print("8. E. failure: always a fresh start, never a crash") for label, content in (("an empty file", ""), ("not JSON", "{not json"), ("JSON of the wrong shape", "[1, 2]"), ("no time of last use", json.dumps({"format": 1, "tabs": THREE, "active": 0})), ("a time that is not a number", json.dumps({"format": 1, "last_activity": "soon", "tabs": THREE})), ("no tabs", json.dumps({"format": 1, "last_activity": NOW, "active": 0, "tabs": []})), ("a future format", json.dumps({"format": 2, "last_activity": NOW, "active": 0, "tabs": THREE})), ("tabs that are not a list", json.dumps({"format": 1, "last_activity": NOW, "tabs": 7}))): path.write_text(content) data, why = ws.read(path, now=NOW + 5) check(f"{label}: fresh ({why}), and removed", data is None and not path.exists(), why) tabs, _ = ws.plan([{"kind": "web"}, "not a tab", {"kind": "program", "where": "/bin/sh"}, ws.tab("web", SLIDE)], 0, downloads) check("tabs that make no sense are skipped, the rest kept", [t["where"] for t in tabs] == [SLIDE], tabs) path.mkdir() data, why = ws.read(path, now=NOW) check("a directory where the file should be: fresh, and no crash", data is None, why) path.rmdir() (path.with_name(path.name + ".new")).write_text('{"format": 1, "last_act') keep() data, why = ws.read(path, now=NOW + 5) check("a write cut off half way (the .new file) leaves the last whole one", data is not None, why) print() print("9. E. a restore that crashes Wonder Ink") keep(attempts=1) data, why = ws.read(path, now=NOW + 60) check("started from once without settling: tried once more", data is not None and data["attempts"] == 1, why) keep(attempts=2) data, why = ws.read(path, now=NOW + 60) check("twice: abandoned, and Wonder Ink starts fresh", data is None and "failed to restore" in why, why) check("and the workspace is gone", not path.exists()) print() print("10. Wonder Ink keeps it, and only as this says") ink = (root / "scripts/classroom-wonderink").read_text() code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#")) keeper = code[code.index("class WorkspaceKeeper"):code.index("class LessonPage")] check("the engine still keeps no cookie", "PersistentCookiesPolicy.NoPersistentCookies" in code) check("the keeper never touches a cookie, a header or a page's storage", not any(w in keeper for w in ("cookieStore", "QNetworkCookie", "setHttpHeader", "localStorage", "runJavaScript"))) check("nothing else in Wonder Ink writes a cookie down", "toRawForm" not in code and "cookieStore()" not in code) check("activity is the teacher's input: button, key, wheel, touch — not the pointer moving", all(f"QEvent.Type.{t}" in keeper for t in ("MouseButtonPress", "KeyPress", "Wheel", "TouchBegin")) and "MouseMove" not in keeper) check("a restore carries the old time of last use over, not now", 'self._last_wall = kept["last_activity"]' in keeper) check("a restore is counted in the file before any tab loads", keeper.index("self._attempts = kept[\"attempts\"] + 1") < keeper.index("for item in tabs:") and "workspace.write(self._path, tabs, active, self._last_wall, self._attempts)" in keeper) check("a restored tab loads when it is first shown, the one in front at once (a hidden PDF opened at 10%)", "self._window._pending[view] = (lambda v=view, u=where: v.load(u))" in keeper and "pending = self._pending.pop(view, None)" in code) check("and until then it is kept by its saved address, not dropped as an empty tab", "if view in self._window._pending:\n tabs.append(self._pending_tabs[view])" in keeper) check("and the count cleared once Wonder Ink has settled", "QTimer.singleShot(self.SETTLED_MS, self._settled)" in keeper) check("the tabs are written when they change, not only at the end", "view.urlChanged.connect(lambda *_: self._workspace_changed())" in code and "tabMoved.connect(lambda *_: self._workspace_changed())" in code and "currentChanged.connect(lambda *_: self._workspace_changed())" in code) check("a page changing its own tab is not activity", "urlChanged.connect(lambda *_: self._workspace_changed())" in code and "touched()" not in code[code.index("def _workspace_changed"):code.index("def _workspace_changed") + 200]) check("at quit it keeps the tabs, then stops filtering events", "aboutToQuit.connect(self.shut_down)" in keeper and "removeEventFilter(self)" in keeper) check("worksheet downloads are connected before the workspace opens", code.index("profile.downloadRequested.connect(window.on_download)\n open_workspace(window)") > 0) setters = [str(p.relative_to(root)) for top in ("config", "packaging", "systemd", "scripts") for p in (root / top).rglob("*") if p.is_file() and not p.is_symlink() and not str(p).startswith(str(root / "packaging" / "root")) and ws.TEST_OVERRIDE in p.read_text(errors="ignore")] check("nothing the classroom installs sets the test-only override", setters == [], setters) check("and Wonder Ink says so in the journal when it is set", "TEST ONLY" in code and "workspace.is_test_limit()" in code) check("the module is packaged", "lib/classroom_wonderink_workspace.py" in (root / "tools/build-release.sh").read_text()) print() print(f"{passed} passed, {failed} failed") sys.exit(1 if failed else 0) PY