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.
395 lines
14 KiB
Python
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())
|