T9.8 - D7: archiving stops reading as deletion - for project admins
Archiving already froze a project (the server refuses every write); what did not exist was the way back in. Now: - GET /api/projects?archived=only|all filters the answer BY PER-PROJECT ROLE: a project admin (or super/app admin) on THAT project sees it; everyone else receives an empty list from the same request - archived projects appear nowhere for them, counts and pickers included (the default listing already excluded them for everyone; asking is what got gated). Admin-on-Job-A does not surface archived Job B. - The launcher gains a visibly separate, labelled "Archived projects" section (dashed border, read-only stated in words), rendered only when the server returns rows. Opening one makes it active; the launcher's reconcile learned that an active project whose stored summary says archived:true was opened ON PURPOSE and keeps it, while a project archived out from under someone still drops with the existing explanation. - The creator shows ARCHIVED - READ-ONLY where the project is named (both ctx-bar branches, from the SERVER's answer - the page's project comes from the URL, so a stale local summary is not trusted) and refuses saves with a reason before the round trip. The courtesy; the server's refusal is the rule, verified by calling the endpoints directly (wp upsert AND the material-list write both refuse with "archived" even for an admin). - No unarchive button, no second mechanism, and it fits at 390px. Verification (each probe run alone): NEW tests/archived_check.py 15/15. Regressions: launcher_check 58/58, sample_check 10/10, export_check 20/20, frame_check 38/38. Items: D7 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
181
tests/archived_check.py
Normal file
181
tests/archived_check.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user