#!/usr/bin/env python3 """Can a project admin get back into an archived project — and only they? — D7, T9.8. Archiving read as deletion because there was no way back in. Now: a separate, labelled, read-only list on the launcher for project admins; the server filters the answer by per-project role, refuses every write regardless of what the browser sends, and shows archived projects to nobody else anywhere - counts and pickers included. Exit 0 all passed, 1 a failure, 2 could not run. """ import json import os import re import sys import tempfile import time sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cdp # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402 from sections_check import set_sop # noqa: E402 from stepper_check import dismiss_dialogs # noqa: E402 from qa_gate_check import api # noqa: E402 def ascii_(v, n=280): return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n] def settle(seconds=0.5): time.sleep(seconds) def archive_projB(db_path): from server.db import SessionLocal from server import models with SessionLocal() as db: proj = db.get(models.Project, "projB") proj.archived_at = models.utcnow() db.commit() def main(): exe = cdp.find_browser() if not exe: print("no headless-capable browser found; set WP_BROWSER.") return 2 tmpdir = tempfile.mkdtemp(prefix="wpsuite-arch-") db_path = os.path.join(tmpdir, "check.db") server = None browser = None try: tok = seed(db_path) set_sop(db_path, {}) archive_projB(db_path) port = cdp.free_port() base = "http://127.0.0.1:%d" % port server = start_server(port, db_path) root, bob, pat = tok["root"], tok["bob"], tok["pat"] # ── 1. who sees what ────────────────────────────────────────────────── print("\n1. visibility, by role") _, rows = api(base, "/api/projects", root) chk("the default list hides archived projects from EVERYONE, admin included", all(p["id"] != "projB" for p in rows), ascii_([p["id"] for p in rows])) _, rows = api(base, "/api/projects?archived=only", root) chk("an admin asking for the archived list gets it", [p["id"] for p in rows] == ["projB"], ascii_(rows)) _, rows = api(base, "/api/projects?archived=only", bob) chk("a plain project user ON that project gets an empty list - no leak", rows == [], ascii_(rows)) _, rows = api(base, "/api/projects?archived=all", bob) chk("...and cannot smuggle it through archived=all either", all(p["id"] != "projB" for p in rows), ascii_(rows)) _, rows = api(base, "/api/projects?archived=only", pat) chk("a user with no access to it sees nothing, same as before", rows == [], ascii_(rows)) # ── 2. the server refuses writes regardless of the browser ─────────── print("\n2. frozen means frozen") code, out = api(base, "/api/wps", root, "POST", { "id": "wpArch1", "project_id": "projB", "number": "AR-1", "subject": "write into the archive", "status": "Draft", "data": {"constraints": []}}) chk("a direct write to an archived project is refused, even for an admin", code in (403, 409) and "archived" in str(out).lower(), ascii_((code, out))) code, _ = api(base, "/api/projects/projB/materials", root, "POST", {"description": "Sample sneak", "unit": "EA"}) chk("...and so is every other write route (material list)", code in (403, 409), code) code, wps = api(base, "/api/wps?project_id=projB", root) chk("reading it still works - archived is readable, not gone", code == 200, code) # ── 3. the launcher, both roles, at 390px ───────────────────────────── print("\n3. the launcher") browser = cdp.Browser(exe) page = browser.page() page.clear_cookies() page.set_cookie("wp_session", root) page.viewport(390, 844, mobile=True) page.goto(base + "/index.html") dismiss_dialogs(page) settle(2.5) sec = json.loads(page.eval("""JSON.stringify((() => { const s = document.getElementById('archived-projects'); return {hidden: !s || s.hidden, text: s ? s.textContent : '', buttons: s ? s.querySelectorAll('button').length : 0}; })())""")) chk("a project admin sees the archived list, separate and labelled", not sec["hidden"] and "Archived projects" in sec["text"] and "read-only" in sec["text"].lower() and sec["buttons"] == 1, ascii_(sec)) chk("...and it fits at 390px", page.eval( "document.getElementById('archived-projects').scrollWidth <= 392")) page.eval("document.querySelector('[data-open-archived]').click()") settle(2.0) chk("opening one makes it the active project", page.eval("(ProjectData.getActive()||{}).id") == "projB") # the creator's read-only courtesy on top of the server's rule page.goto(base + "/wp-creation-index.html?project=projB") dismiss_dialogs(page) settle(2.5) page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;") chk("the creator says ARCHIVED where the project is named", "ARCHIVED" in page.eval( "(document.getElementById('ctx-bar')||{textContent:''}).textContent")) page.eval("document.getElementById('wp_subject').value='x'") page.eval("document.getElementById('wp_type').value='Conduit Install'") n0 = page.eval("savedPackages.length") page.eval("void savePackage(false)") settle(0.8) chk("saving is refused with a reason, before the round trip", page.eval("savedPackages.length") == n0 and "archived" in page.eval( "(document.getElementById('toast')||{textContent:''}).textContent").lower()) # a NON-admin's launcher shows no archived section at all page.clear_cookies() page.set_cookie("wp_session", bob) page.goto(base + "/index.html") dismiss_dialogs(page) settle(2.5) chk("a non-admin's launcher never shows the section", page.eval("(() => { const s=document.getElementById('archived-projects');" " return !s || s.hidden; })()")) # projB has no SOP, and GET /api/sops/latest answering 404 for it is the # correct answer, not an error - the seed fixture documents exactly this # false alarm. js_errors = [e for e in page.js_errors() if "beforeunload" not in e and "sops/latest" not in e] chk("no JavaScript errors anywhere in this run", not js_errors, ascii_(js_errors[:2])) finally: if browser is not None: try: browser.close() except Exception: pass if server is not None: try: server.terminate() except Exception: pass print("\n" + "-" * 54) print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL))) for f in _FAIL: print(" - " + f) return 1 if _FAIL else 0 if __name__ == "__main__": sys.exit(main())