Closes known issue 3. server/smoketest.py predated the login portal and had no
login step at all, so auth_gate refused every route after /api/health and the
documented way to verify a deploy reported a wall of failures against a healthy
stack.
- Signs in first, holding the session in an http.cookiejar on a shared opener.
urlopen() has no cookie support, which is why the session was dropped.
- Credentials from WP_SMOKE_USER / WP_SMOKE_PASSWORD, or --user/--password, so
a password need not land in shell history. Refuses to start without them
rather than running headlong into 401s.
- Checks the signed-in role up front and warns when it cannot archive or delete
a project, instead of failing six checks later for an unexplained reason.
- New exit code 2 for "could not run" (unreachable, or credentials missing or
rejected), kept distinct from 1 "ran and found problems".
- Also asserts the session is accepted on an authenticated route and refused
after sign-out; signs out at the end so a run on a shared host leaves none.
The working smoke test immediately caught a real bug: SQLite ships with foreign
keys disabled and the pragma is per-connection, so every ondelete="CASCADE" was
silently a no-op on dev while working on Postgres. Deleting a project orphaned its
SOPs, work packages and membership rows; deleting a user orphaned theirs. db.py
now sets PRAGMA foreign_keys=ON for SQLite, so dev matches production.
Enforcing them exposed two things that had been getting away with it:
- create_user adds an account and its ProjectMember rows in one flush, and the
ORM takes flush order from relationship() declarations. models.py has none by
design, so it emitted the child INSERT first and the database rejected it.
Fixed with a db.flush() after the account, and documented at the top of
models.py so the next same-flush pair does not rediscover it. The other three
call sites already commit the parent first.
- A write aimed at a since-deleted project used to leave an orphan row; with FKs
enforced it would have been an IntegrityError surfacing as a 500, which the
browser outbox retries forever (it only retires 4xx). require_project_writable
now refuses a vanished project with 409, like the archived case beside it.
Verified: smoke test 27/27 exit 0 against a live server (the cascade assertion now
passes on SQLite, which is what used to fail); credentials missing and credentials
rejected both abort cleanly with exit 2 and no stray PASS lines; a project_user run
warns up front and fails as described. Scope tests 93/93, live HTTP checks 29/29,
static JS checks 33/33. No orphan rows left in the database afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
330 lines
18 KiB
Python
330 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""End-to-end smoke test for the Work Package Suite API + PostgreSQL.
|
|
|
|
Exercises the real HTTP endpoints the way the front end does, proving that
|
|
NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
|
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
|
|
|
AUTHENTICATION
|
|
Every /api/ route except /api/health requires a session (auth_gate in
|
|
server/app.py), so the script signs in first and keeps the session cookie for
|
|
the rest of the run. Credentials come from the environment by preference, so a
|
|
password never has to appear in a command line or shell history:
|
|
|
|
export WP_SMOKE_USER=smoketest
|
|
export WP_SMOKE_PASSWORD='…'
|
|
python3 server/smoketest.py https://wp-suite.company.local
|
|
|
|
…or pass --user / --password explicitly.
|
|
|
|
Use an ADMIN account. The script creates a project and deletes it again at the
|
|
end, and deleting one takes Project Admin on that project (require_project_admin);
|
|
a plain project_user can create a project but not clean it up. The script checks
|
|
the signed-in role up front and warns if it is too low, rather than letting you
|
|
discover it in the cleanup step.
|
|
|
|
USAGE
|
|
# Against the deployed site (through the NGINX proxy):
|
|
python3 server/smoketest.py https://wp-suite.company.local
|
|
|
|
# Self-signed / internal TLS cert? skip verification:
|
|
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
|
|
|
# From inside the api container (hits FastAPI directly):
|
|
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
|
|
python /app/server/smoketest.py http://localhost:8000
|
|
|
|
# Leave the demo project in the database so you can open it in the UI:
|
|
python3 server/smoketest.py https://wp-suite.company.local --keep
|
|
|
|
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
|
|
|
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
|
|
could not start (unreachable host, missing or rejected credentials). 2 is kept
|
|
distinct on purpose: "I could not test this" is not the same answer as "this is
|
|
broken", and conflating them is what made an unauthenticated version of this
|
|
script report a wall of failures against a perfectly healthy stack.
|
|
"""
|
|
import argparse
|
|
import http.cookiejar
|
|
import json
|
|
import os
|
|
import ssl
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
# ── tiny colored reporter ─────────────────────────────────────────────────────
|
|
_PASS, _FAIL = [], []
|
|
def _c(s, code): # color if a TTY
|
|
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
|
|
def ok(msg): _PASS.append(msg); print(" " + _c("PASS", "32") + " " + msg)
|
|
def bad(msg): _FAIL.append(msg); print(" " + _c("FAIL", "31") + " " + msg)
|
|
def check(name, cond, detail=""):
|
|
(ok if cond else bad)(name + (f" ({detail})" if detail and not cond else ""))
|
|
return cond
|
|
|
|
BASE = ""
|
|
CTX = None
|
|
# One opener for the whole run, carrying the cookie jar that holds the session
|
|
# issued by /api/auth/login. urlopen() has no cookie support, which is why the
|
|
# session used to be dropped on the floor and every data route answered 401.
|
|
OPENER = None
|
|
|
|
|
|
def build_opener(ctx=None):
|
|
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
|
|
if ctx is not None:
|
|
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
|
return urllib.request.build_opener(*handlers)
|
|
|
|
|
|
def call(method, path, body=None):
|
|
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
|
url = BASE + path
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(
|
|
url, data=data, method=method,
|
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
)
|
|
try:
|
|
with OPENER.open(req, timeout=20) as r:
|
|
raw = r.read().decode(); status = r.status
|
|
except urllib.error.HTTPError as e:
|
|
raw = e.read().decode(); status = e.code
|
|
try:
|
|
parsed = json.loads(raw) if raw else None
|
|
except ValueError:
|
|
parsed = raw
|
|
return status, parsed
|
|
|
|
|
|
def abort(msg, hint=""):
|
|
"""Could not run — distinct from 'ran and found problems'. See exit codes above."""
|
|
print(_c("\nABORT", "31") + " " + msg)
|
|
if hint:
|
|
print(hint)
|
|
print()
|
|
return 2
|
|
|
|
|
|
def main():
|
|
global BASE, CTX, OPENER
|
|
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
|
|
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
|
help="Site root, no /api (default: http://localhost:8000)")
|
|
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
|
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
|
|
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
|
|
help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
|
|
ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
|
|
help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
|
|
"so it stays out of shell history)")
|
|
args = ap.parse_args()
|
|
BASE = args.base_url.rstrip("/")
|
|
if args.insecure:
|
|
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
|
OPENER = build_opener(CTX)
|
|
|
|
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
|
|
|
# Refuse to start without credentials rather than running headlong into 401s.
|
|
if not args.user or not args.password:
|
|
missing = " and ".join(
|
|
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
|
|
return abort(
|
|
f"no credentials — {missing} not set.",
|
|
" Every /api/ route except /api/health needs a session, so there is nothing\n"
|
|
" meaningful to test without one. Set them and re-run:\n\n"
|
|
" export WP_SMOKE_USER=<admin-account>\n"
|
|
" export WP_SMOKE_PASSWORD='…'\n\n"
|
|
" Or pass --user/--password. Use an admin account: the run creates a project\n"
|
|
" and deletes it again, and the delete needs Project Admin on it.")
|
|
|
|
project_id = None
|
|
# Guards the sign-out in `finally`. Without it an ABORT on a rejected login still
|
|
# ran the logout checks, which "passed" — a session that never existed is trivially
|
|
# refused after logout — and printed PASS lines underneath an abort message.
|
|
logged_in = False
|
|
try:
|
|
# 1) Health — API is up and reachable through the proxy. Exempt from auth,
|
|
# so this also isolates "host unreachable" from "credentials rejected".
|
|
try:
|
|
st, body = call("GET", "/api/health")
|
|
except urllib.error.URLError as e:
|
|
return abort(f"cannot reach {BASE}/api/health — {e}",
|
|
" Is the stack up (docker compose ps) and the URL correct?")
|
|
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
|
f"status={st} body={body}")
|
|
|
|
# 2) Sign in. The cookie the response sets is held by OPENER's jar and rides
|
|
# every request after this one.
|
|
st, body = call("POST", "/api/auth/login",
|
|
{"username": args.user, "password": args.password})
|
|
if st != 200:
|
|
detail = body.get("detail") if isinstance(body, dict) else body
|
|
hint = (" The account may be locked: the API locks an account for a while after\n"
|
|
" a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n"
|
|
" so re-running with the wrong password makes this worse, not better.\n"
|
|
" Check the password, then wait out the lockout window."
|
|
if st in (401, 403, 423, 429) else
|
|
" Unexpected status from the login endpoint — check the API logs.")
|
|
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
|
|
logged_in = True
|
|
check("login issues a session", st == 200)
|
|
|
|
# 3) Prove the session actually travels — this is the check whose absence let
|
|
# an unauthenticated version of this script look like a broken stack.
|
|
st, me = call("GET", "/api/auth/me")
|
|
who = (me or {}).get("user", {}) if isinstance(me, dict) else {}
|
|
check("session is accepted on an authenticated route",
|
|
st == 200 and who.get("username", "").lower() == args.user.lower(),
|
|
f"status={st} body={me}")
|
|
role = who.get("role", "?")
|
|
print(f" ..... signed in as {who.get('username', args.user)} (role: {role})")
|
|
if role not in ("admin", "project_super_user", "project_admin"):
|
|
print(_c(" NOTE", "33") + f" '{role}' cannot archive or delete a project, so the "
|
|
"archive checks and the\n cleanup step will fail and a stray test project "
|
|
"will be left behind.\n Re-run with an admin account for a clean pass.")
|
|
|
|
# 4) Create a project (writes to the projects table).
|
|
st, proj = call("POST", "/api/projects", {
|
|
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
|
|
"client": "Internal QA", "division": "Controls", "site": "Test Host",
|
|
"created_by": "smoketest",
|
|
})
|
|
project_id = proj.get("id") if isinstance(proj, dict) else None
|
|
check("create project", st == 200 and bool(project_id), f"status={st}")
|
|
|
|
# 5) Read it back + confirm it's in the list (SQL round-trip).
|
|
st, got = call("GET", f"/api/projects/{project_id}")
|
|
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
|
|
st, lst = call("GET", "/api/projects")
|
|
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
|
|
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
|
|
|
|
# 6) Create a SOP linked to the project.
|
|
st, sop = call("POST", "/api/sops", {
|
|
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
|
|
"complete": True, "created_by": "smoketest",
|
|
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
|
|
"disciplines": ["Mechanical", "Electrical", "Tech"]}},
|
|
})
|
|
sop_id = sop.get("id") if isinstance(sop, dict) else None
|
|
check("create SOP linked to project", st == 200 and bool(sop_id) and sop.get("project_id") == project_id,
|
|
f"status={st}")
|
|
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
|
|
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
|
|
|
|
# 7) Create a Work Package with one OPEN constraint (not release-ready).
|
|
st, wp = call("POST", "/api/wps", {
|
|
"project_id": project_id, "sop_id": sop_id,
|
|
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
|
"status": "Scheduled", "created_by": "smoketest",
|
|
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
|
"constraints": [{"name": "Materials", "status": "open", "comment": "awaiting delivery"},
|
|
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
|
|
})
|
|
wp_id = wp.get("id") if isinstance(wp, dict) else None
|
|
check("create work package", st == 200 and bool(wp_id), f"status={st}")
|
|
|
|
# 8) The AWP release gate: issuing with an open constraint must be REFUSED (409).
|
|
st, refused = call("POST", f"/api/wps/{wp_id}/issue")
|
|
check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
|
|
|
|
# 9) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
|
|
call("POST", "/api/wps", {
|
|
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
|
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
|
"status": "Scheduled",
|
|
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
|
"constraints": [{"name": "Materials", "status": "cleared", "comment": ""},
|
|
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
|
|
})
|
|
st, issued = call("POST", f"/api/wps/{wp_id}/issue")
|
|
check("issue succeeds once constraints clear", st == 200 and issued.get("status") == "Issued",
|
|
f"status={st}")
|
|
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
|
|
|
|
# 10) Status transition endpoint.
|
|
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"})
|
|
check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
|
|
|
|
# 11) Metrics aggregate for the project (Python aggregation over SQL rows).
|
|
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}")
|
|
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
|
|
f"status={st} metrics={m}")
|
|
|
|
# 12) Comment / feedback write + read.
|
|
st, c = call("POST", "/api/feedback", {
|
|
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
|
|
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
|
|
check("post comment/feedback", st == 200 and isinstance(c, dict) and bool(c.get("id")), f"status={st}")
|
|
st, comments = call("GET", f"/api/comments?wp_id={wp_id}")
|
|
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
|
|
f"status={st}")
|
|
|
|
# 13) WPs filter by project.
|
|
st, wps = call("GET", f"/api/wps?project_id={project_id}")
|
|
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
|
|
|
|
# 14) Archiving a project: it leaves the default list, stays reachable with
|
|
# archived=all, and freezes read-only — then unarchiving restores all three.
|
|
# The freeze is the whole point of the feature, so it is asserted, not assumed.
|
|
st, arch = call("POST", f"/api/projects/{project_id}/archive", {"archived": True})
|
|
check("archive project", st == 200 and arch.get("archived") is True, f"status={st}")
|
|
st, lst = call("GET", "/api/projects")
|
|
check("archived project drops out of the default list",
|
|
st == 200 and not any(p.get("id") == project_id for p in lst), f"status={st}")
|
|
st, lst = call("GET", "/api/projects?archived=all")
|
|
check("archived project is still there with archived=all",
|
|
st == 200 and any(p.get("id") == project_id for p in lst), f"status={st}")
|
|
st, refused = call("POST", "/api/wps", {
|
|
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
|
"number": "WP01-SMOKE", "subject": "edited while archived", "type": "Conduit Install",
|
|
"status": "Scheduled", "data": {"disciplines": ["Electrical"], "hours": "40"}})
|
|
check("writing to an archived project is refused (409)", st == 409, f"status={st} body={refused}")
|
|
st, unarch = call("POST", f"/api/projects/{project_id}/archive", {"archived": False})
|
|
check("unarchive project", st == 200 and unarch.get("archived") is False, f"status={st}")
|
|
st, _ = call("POST", "/api/wps", {
|
|
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
|
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
|
"status": "In Progress", "data": {"disciplines": ["Electrical"], "hours": "40"}})
|
|
check("writing succeeds again once unarchived", st == 200, f"status={st}")
|
|
|
|
finally:
|
|
# 15) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
|
if project_id and not args.keep:
|
|
st, _ = call("DELETE", f"/api/projects/{project_id}")
|
|
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
|
|
st, after = call("GET", f"/api/wps?project_id={project_id}")
|
|
check("WPs removed by cascade", st == 200 and isinstance(after, list) and len(after) == 0,
|
|
f"status={st} remaining={after}")
|
|
elif project_id and args.keep:
|
|
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
|
|
|
|
# 16) Sign out. Exercises the logout endpoint, and means a run does not end
|
|
# holding a live session — which matters when this is run from a shared
|
|
# jump host or a CI worker. Only if we got one: see `logged_in`.
|
|
if logged_in:
|
|
st, _ = call("POST", "/api/auth/logout")
|
|
check("logout clears the session", st == 200, f"status={st}")
|
|
st, _ = call("GET", "/api/auth/me")
|
|
check("session is refused after logout (401)", st == 401, f"status={st}")
|
|
|
|
# ── summary ────────────────────────────────────────────────────────────────
|
|
total = len(_PASS) + len(_FAIL)
|
|
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")
|
|
if _FAIL:
|
|
print(_c(f"FAILED ({len(_FAIL)}):", "31"))
|
|
for f in _FAIL:
|
|
print(" - " + f)
|
|
print("\nResult: " + _c("FAIL", "31") + "\n")
|
|
return 1
|
|
print("\nResult: " + _c("ALL PASS — API, Python logic, and SQL are working.", "32") + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|