Workflow-App/Workflow/scripts/smoke_phase6.py
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

395 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Phase 6 smoke driver — runs the lib/letter.ts → scripts/subst.py → soffice
pipeline on a mocked LetterContext with no dev server, no PB, no himalaya.
What it verifies:
1. subst.py produces a docx with all placeholders substituted
(lib/letter.ts uses a separate `unfilled_placeholders` scan; we re-do
it here with a simple regex grep over both docx XML AND the rendered PDF).
2. Signature PNGs are embedded and visible on each attendee's signature line.
3. soffice renders the docx → PDF successfully (file size > 5KB suggests
signatures actually wrote to the PDF; < 2KB means it was empty / broken).
Output:
- out.docx
- out.pdf
- printout of (a) unfilled placeholders, (b) PDF page count, (c) whether
signature PNG bytes appeared in the rendered PDF.
Run:
/Volumes/KIOXIA/.hermes/profiles/ysterbaard/APPS/Workflow/scripts/.venv/bin/python3 \
/Volumes/KIOXIA/.hermes/profiles/ysterbaard/APPS/Workflow/scripts/smoke_phase6.py
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
import docx # python-docx, must already be installed in the venv
ROOT = Path("/Volumes/KIOXIA/.hermes/profiles/ysterbaard/APPS/Workflow")
PROFILE_ROOT = Path("/Volumes/KIOXIA/.hermes/profiles/ysterbaard")
TEMPLATE = PROFILE_ROOT / "assets" / "templates" / "director_change_resolution_master.docx"
SUBST = ROOT / "scripts" / "subst.py"
VENV_PY = ROOT / "scripts" / ".venv" / "bin" / "python3"
SOFFICE = "/opt/homebrew/bin/soffice"
# Placeholders the master docx BEGINS with (per ysterbaard-resolutions skill
# "Letter template" + "Phase 6 master docx fixes" memo). After substitution
# we should see NONE of these still wrapped in [ ] in the output body, with
# the notable exception of the preserved legal typos (POSSITIONS, RESIGEND,
# ADDITIOAN, SURANEM) which are PART of the legal artefact and are not
# placeholders — they get filled in by SDLB-specific logic. Our substitution
# script should NOT touch those (per the skill: "Preserve the docx typos
# verbatim").
LEGAL_PRESERVED_TYPOS = {"POSSITIONS", "RESIGEND", "ADDITIOAN", "SURANEM"}
def make_signature_png(path: Path, label: str) -> Path:
"""Generate a tiny black-on-white signature PNG using PIL — no scanner needed."""
from PIL import Image, ImageDraw, ImageFont
img = Image.new("RGB", (300, 100), "white")
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 36)
except Exception:
font = ImageFont.load_default()
draw.text((20, 30), f"~ signed: {label} ~", fill="black", font=font)
img.save(path, "PNG")
return path
def make_letter_context(workdir: Path, scenario: str = "full") -> dict:
"""LetterContext shape that lib/letter-runner.ts produces.
Scenarios (Phase 6 extension):
"full" — Alice (existing), Bob (resigning), Carol (newly appointed).
"no_resign" — Alice (existing), Carol (newly appointed). No resignations.
"no_appoint" — Alice (existing), Bob (resigning). No appointments.
"no_either" — Alice + Diana (both existing). No resignations, no appointments.
"""
base_attendees = [
# Alice — existing director (in every scenario)
{
"id": "att-alice",
"name": "Alice",
"surname": "Anderson",
"position": "Director",
"email": "alice@example.com",
"attended": True,
"must_sign": True,
"director_role": "EXISTING",
"sign_order": 1,
"director_id_number": "8001010001081",
"effective_date": None,
},
]
if scenario in ("full", "no_appoint"):
base_attendees.append({
"id": "att-bob",
"name": "Bob",
"surname": "Brown",
"position": "Director",
"email": "bob@example.com",
"attended": True,
"must_sign": True,
"director_role": "RESIGNED DIRECTOR",
"sign_order": 2,
"director_id_number": "7505050002082",
"effective_date": "2026-08-22",
})
if scenario in ("full", "no_resign"):
base_attendees.append({
"id": "att-carol",
"name": "Carol",
"surname": "Cohen",
"position": "Director",
"email": "carol@example.com",
"attended": True,
"must_sign": True,
"director_role": "NEW DIRECTOR",
"sign_order": 3,
"director_id_number": "9009090003083",
"effective_date": "2026-08-22",
})
if scenario == "no_either":
base_attendees.append({
"id": "att-diana",
"name": "Diana",
"surname": "Davis",
"position": "Director",
"email": "diana@example.com",
"attended": True,
"must_sign": True,
"director_role": "EXISTING",
"sign_order": 2,
"director_id_number": "8504040004084",
"effective_date": None,
})
# Build signer list — every attendee who has signed. The signature
# generator only generates PNGs for Bob and Carol; other scenarios
# therefore expect pdfimages count == 0 (no signatures in the
# rendered PDF — the meeting only has existing directors who have
# nothing to sign on this resolution).
signers = []
for att in base_attendees:
if att["director_role"] == "RESIGNED DIRECTOR":
sig_path = str(workdir / "sig_bob.png")
signer = {
"id": f"signer-{att['id']}",
"attendee_id": att["id"],
"status": "signed",
"signature_path": sig_path,
"signed_at": "2026-08-22T11:00:00.000Z",
"sent_at": "2026-08-22T09:00:00.000Z",
"email_to": att["email"],
"attendee": att,
}
signers.append(signer)
elif att["director_role"] == "NEW DIRECTOR":
sig_path = str(workdir / "sig_carol.png")
signer = {
"id": f"signer-{att['id']}",
"attendee_id": att["id"],
"status": "signed",
"signature_path": sig_path,
"signed_at": "2026-08-22T11:05:00.000Z",
"sent_at": "2026-08-22T09:00:00.000Z",
"email_to": att["email"],
"attendee": att,
}
signers.append(signer)
# Resolve director labels per letter-runner.ts logic.
resigners = [a for a in base_attendees if a["director_role"] == "RESIGNED DIRECTOR"]
newly_appointed = [a for a in base_attendees if a["director_role"] == "NEW DIRECTOR"]
def _label(people):
if not people:
return None
parts = []
for p in people:
base = f"{p['name']} {p['surname']}"
id_num = p.get("director_id_number")
if id_num:
base = f"{base}, ID {id_num}"
parts.append(base)
return " and ".join(parts)
resigning_director_label = _label(resigners)
newly_appointed_director_label = _label(newly_appointed)
return {
"letter_type": "director_change",
"resolution_id": "smoke-test-id",
"meeting_date": "2026-08-22",
"company": {
"name": "SMOKE TEST COMPANY",
"enterprise_no": "K2099999999",
"address": "123 Test Street, Testville, Gauteng, 2000",
"address_city": "Testville",
},
"attendees": base_attendees,
"signers": signers,
"resigning_director_label": resigning_director_label,
"newly_appointed_director_label": newly_appointed_director_label,
}
SCENARIOS = ["full", "no_resign", "no_appoint", "no_either"]
def find_unfilled_placeholders(docx_path: Path) -> list[str]:
"""Replicates lib/letter.ts `unfilled_placeholders()` logic."""
out: list[str] = []
with zipfile.ZipFile(docx_path) as z:
for name in z.namelist():
if not name.endswith(".xml"):
continue
data = z.read(name).decode("utf-8", errors="ignore")
for m in re.finditer(r"\[([A-Z][A-Z _/,-]{2,40})\]", data):
placeholder = m.group(0)
inner = m.group(1)
# Skip preserved legal typos
if inner in LEGAL_PRESERVED_TYPOS:
continue
if placeholder not in out:
out.append(placeholder)
return out
def main() -> int:
if not TEMPLATE.exists():
print(f"FAIL: template not found at {TEMPLATE}")
return 1
if not SUBST.exists():
print(f"FAIL: subst.py not found at {SUBST}")
return 1
if not VENV_PY.exists():
print(f"FAIL: venv python missing at {VENV_PY}")
return 1
if not os.path.exists(SOFFICE):
print(f"FAIL: soffice missing at {SOFFICE}")
return 1
# Argument parsing — `--scenario X` runs a single case; otherwise all 4.
args = sys.argv[1:]
scenarios_to_run = SCENARIOS
if "--scenario" in args:
idx = args.index("--scenario")
arg_value = args[idx + 1] if idx + 1 < len(args) else "full"
if arg_value not in SCENARIOS:
print(f"FAIL: unknown scenario '{arg_value}'. Valid: {SCENARIOS}")
return 1
scenarios_to_run = [arg_value]
overall_ok = True
for scenario in scenarios_to_run:
ok = run_scenario(scenario)
if not ok:
overall_ok = False
return 0 if overall_ok else 4
def run_scenario(scenario: str) -> bool:
workdir = Path(tempfile.mkdtemp(prefix=f"ybl-smoke-{scenario}-"))
print(f"\n========== scenario: {scenario} ==========")
print(f"workdir: {workdir}")
# Generate signature PNGs only for scenarios that need them.
sig_bob = workdir / "sig_bob.png"
sig_carol = workdir / "sig_carol.png"
if scenario in ("full", "no_appoint"):
make_signature_png(sig_bob, "Bob Brown")
if scenario in ("full", "no_resign"):
make_signature_png(sig_carol, "Carol Cohen")
print(f"signature PNGs: "
f"bob={sig_bob.stat().st_size if sig_bob.exists() else 0}B "
f"carol={sig_carol.stat().st_size if sig_carol.exists() else 0}B")
# LetterContext JSON
ctx_path = workdir / "context.json"
ctx = make_letter_context(workdir, scenario)
ctx_path.write_text(json.dumps(ctx, indent=2))
expected_sigs = sum(1 for s in ctx["signers"] if s["status"] == "signed")
# Run subst.py
out_docx = workdir / "out.docx"
res = subprocess.run(
[str(VENV_PY), str(SUBST), str(TEMPLATE), str(out_docx), str(ctx_path), str(workdir)],
capture_output=True,
text=True,
timeout=120,
)
print(f"\nsubst.py exit: {res.returncode}")
if res.stdout.strip():
print("stdout:", res.stdout.strip()[:500])
if res.stderr.strip():
print("stderr:", res.stderr.strip()[:500])
if res.returncode != 0:
print("FAIL: subst.py non-zero exit")
return False
print(f"out.docx size: {out_docx.stat().st_size} B")
# Render to PDF
out_pdf = workdir / "out.pdf"
soff = subprocess.run(
[SOFFICE, "--headless", "--convert-to", "pdf", "--outdir", str(workdir), str(out_docx)],
capture_output=True,
text=True,
timeout=120,
)
print(f"\nsoffice exit: {soff.returncode}")
if soff.returncode != 0:
print("FAIL: soffice convert failed")
print(soff.stderr[:500])
return False
print(f"out.pdf size: {out_pdf.stat().st_size} B")
# Verify (1) no unfilled placeholders
unfilled = find_unfilled_placeholders(out_docx)
print(f"\nunfilled placeholders (after subst): {unfilled if unfilled else 'NONE ✓'}")
# Verify (2) signature PNGs landed in the rendered PDF.
pdfimages_count = 0
if shutil.which("pdfimages"):
img_list = subprocess.run(
["pdfimages", "-list", str(out_pdf)],
capture_output=True, text=True, timeout=30,
)
for line in img_list.stdout.splitlines():
line = line.strip()
if line and line[0].isdigit():
pdfimages_count += 1
print(f"pdfimages count: {pdfimages_count} (need ≥ {expected_sigs})")
else:
print("WARNING: pdfimages not installed — skipping image check")
pdfimages_ok = pdfimages_count >= expected_sigs
# Quick sanity: number of pages
pdf_bytes = out_pdf.read_bytes()
page_count_match = re.search(rb"/Count\s+(\d+)", pdf_bytes)
page_count = page_count_match.group(1).decode() if page_count_match else "?"
print(f"page count: {page_count}")
# Per-scenario content sanity: extract the joined paragraph text from
# out.docx and check the expected sub-sections rendered correctly.
docx_text = ""
with zipfile.ZipFile(out_docx) as z:
doc_xml = z.read("word/document.xml").decode("utf-8", errors="ignore")
# Strip XML tags to get joined text
docx_text = re.sub(r"<[^>]+>", " ", doc_xml)
docx_text = re.sub(r"\s+", " ", docx_text)
has_none_notice = "(None this meeting.)" in docx_text
if scenario in ("no_resign", "no_either"):
if not has_none_notice:
print(f"FAIL: scenario {scenario!r} expects at least one "
f"'(None this meeting.)' notice, found 0")
else:
print(f"'(None this meeting.)' notice: present ✓")
if scenario in ("no_appoint", "no_either"):
if not has_none_notice:
print(f"FAIL: scenario {scenario!r} expects at least one "
f"'(None this meeting.)' notice, found 0")
else:
print(f"'(None this meeting.)' notice: present ✓")
if scenario in ("no_resign", "no_either"):
# Should NOT contain Bob Brown as the resigning director
# (Bob is only added to attendees when scenario in {full, no_appoint}).
if scenario == "no_resign" and "Bob Brown" in docx_text:
print(f"FAIL: scenario 'no_resign' should not contain 'Bob Brown' "
f"in the minute body")
return False
ok = (
res.returncode == 0
and soff.returncode == 0
and not unfilled
and pdfimages_ok
and out_pdf.stat().st_size > 2000
)
if scenario in ("no_resign", "no_appoint", "no_either") and not has_none_notice:
ok = False
print(f"\nRESULT ({scenario}): {'PASS ✓' if ok else 'FAIL ✗'}")
return ok
if __name__ == "__main__":
sys.exit(main())