lawn-estimator-dev / tests /test_config_validation.py
TempuraML's picture
feat: WS5 — service bundles (config builder → widget packages → Overview) (#138)
7507549 unverified
Raw
History Blame Contribute Delete
8.76 kB
"""Tenant-config validation (2b) — the config-editor security boundary."""
from __future__ import annotations
import copy
from lawn_estimator.config_validation import validate_tenant_config
VALID = {
"company": "Acme Lawn",
"currency": "USD",
"presentation": "firm",
"allowed_origins": ["https://acme.com", "https://www.acme.com"],
"email": {"from": "Acme <quotes@acme.com>", "reply_to": "r@acme.com", "lead_notify": "leads@acme.com"},
"service_area_zips": ["68106", "68164"],
"services": [
{"id": "mowing", "label": "Mowing", "rate_per_1000_sqft": 5, "min_charge": 45},
{"id": "aeration", "label": "Aeration", "rate_per_1000_sqft": 20, "min_charge": 75},
{"id": "overseeding", "label": "Overseeding", "rate_per_1000_sqft": 25, "min_charge": 0,
"requires": "aeration"},
],
"areas": [
{"name": "Metro", "zips": ["68106"], "travel_time_factor": 1.0},
{"name": "Outer", "zips": ["68022"], "travel_time_factor": 1.25},
],
"branding": {"name": "Acme Lawn", "accent": "#2e7d32", "cta_label": "Book now",
"cta_url": "https://acme.com/book", "logo": "https://acme.com/logo.png"},
}
def _cfg(**overrides):
c = copy.deepcopy(VALID)
c.update(overrides)
return c
def test_a_valid_config_passes_clean():
r = validate_tenant_config(VALID)
assert r.ok and r.errors == []
# --- SECURITY: branding renders in the public widget -----------------------
def test_javascript_url_in_cta_is_rejected():
r = validate_tenant_config(_cfg(branding={**VALID["branding"], "cta_url": "javascript:alert(1)"}))
assert not r.ok
assert any("cta_url" in e for e in r.errors)
def test_javascript_and_html_data_url_in_logo_is_rejected():
for bad in ("javascript:alert(1)", "data:text/html,<script>alert(1)</script>", "http://x/l.png"):
r = validate_tenant_config(_cfg(branding={**VALID["branding"], "logo": bad}))
assert not r.ok, bad
assert any("logo" in e for e in r.errors)
def test_data_image_logo_is_allowed():
r = validate_tenant_config(_cfg(branding={**VALID["branding"], "logo": "data:image/png;base64,AAAA"}))
assert r.ok
def test_accent_must_be_hex():
assert not validate_tenant_config(_cfg(branding={**VALID["branding"], "accent": "red"})).ok
assert not validate_tenant_config(_cfg(branding={**VALID["branding"], "accent": "#12"})).ok
assert validate_tenant_config(_cfg(branding={**VALID["branding"], "accent": "#123abc"})).ok
# --- pricing fat-finger / negatives ----------------------------------------
def test_negative_and_over_cap_prices_are_rejected():
neg = _cfg(services=[{"id": "mowing", "label": "M", "rate_per_1000_sqft": -1, "min_charge": 0}])
assert not validate_tenant_config(neg).ok
fat = _cfg(services=[{"id": "mowing", "label": "M", "rate_per_1000_sqft": 5000, "min_charge": 0}])
assert not validate_tenant_config(fat).ok
def test_dangling_requires_is_rejected():
bad = _cfg(services=[{"id": "overseeding", "label": "O", "rate_per_1000_sqft": 25,
"min_charge": 0, "requires": "aeration"}]) # aeration not present
assert not validate_tenant_config(bad).ok
def test_bundle_validation():
ok = _cfg(bundles=[{"id": "spring", "name": "Spring Package", "service_ids": ["mowing", "aeration"],
"mode": "fixed", "amount": 100, "cadence": "seasonal"}])
assert validate_tenant_config(ok).ok
dangling = _cfg(bundles=[{"id": "x", "name": "X", "service_ids": ["nope"], "mode": "fixed", "amount": 10}])
assert not validate_tenant_config(dangling).ok # references a non-existent service
badpct = _cfg(bundles=[{"id": "x", "name": "X", "service_ids": ["mowing"], "mode": "percent", "amount": 150}])
assert not validate_tenant_config(badpct).ok # >100% discount
badmode = _cfg(bundles=[{"id": "x", "name": "X", "service_ids": ["mowing"], "mode": "free", "amount": 1}])
assert not validate_tenant_config(badmode).ok
noservices = _cfg(bundles=[{"id": "x", "name": "X", "service_ids": [], "mode": "fixed", "amount": 1}])
assert not validate_tenant_config(noservices).ok
def test_travel_time_factor_bounds():
assert not validate_tenant_config(_cfg(areas=[{"name": "A", "zips": ["68106"], "travel_time_factor": 0}])).ok
assert not validate_tenant_config(_cfg(areas=[{"name": "A", "zips": ["68106"], "travel_time_factor": 5}])).ok
# --- format checks ----------------------------------------------------------
def test_bad_zip_is_rejected():
assert not validate_tenant_config(_cfg(service_area_zips=["6810"])).ok
assert not validate_tenant_config(_cfg(service_area_zips=["abcde"])).ok
def test_service_area_polygon_validation():
good = _cfg(service_area_polygon={
"type": "Polygon",
"coordinates": [[[-96.0, 41.2], [-95.9, 41.2], [-95.9, 41.3], [-96.0, 41.3], [-96.0, 41.2]]]})
assert validate_tenant_config(good).ok
assert validate_tenant_config(_cfg(service_area_polygon={"type": "Point", "coordinates": [0, 0]})).ok is False
bad_coords = _cfg(service_area_polygon={"type": "Polygon", "coordinates": [[[200, 41.2], [1, 2]]]})
assert not validate_tenant_config(bad_coords).ok
def test_bad_presentation_and_origin_and_email():
assert not validate_tenant_config(_cfg(presentation="maybe")).ok
assert not validate_tenant_config(_cfg(allowed_origins=["not a url"])).ok
assert not validate_tenant_config(
_cfg(email={**VALID["email"], "from": "not-an-email"})).ok
def test_custom_service_allowed_and_http_cta_warns():
# Services are tenant-defined + auto-ID'd now, so a non-standard id no longer warns; an
# http (not https) cta still warns.
r = validate_tenant_config(_cfg(
services=[{"id": "gutter_cleaning", "label": "Gutters", "rate_per_1000_sqft": 5, "min_charge": 0}],
branding={**VALID["branding"], "cta_url": "http://acme.com/book"}))
assert r.ok # allowed
assert any("http" in w for w in r.warnings) # the cta warning
assert not any("standard service" in w for w in r.warnings)
def test_pricing_units_validated():
# A per-sqft rate priced like a per-1000 rate (5 $/sqft is $5/sqft = way over) is flagged.
over = _cfg(services=[{"id": "mow", "label": "Mow", "unit": "per_sqft", "rate": 9, "min_charge": 0}])
assert not validate_tenant_config(over).ok
bad_unit = _cfg(services=[{"id": "mow", "label": "Mow", "unit": "per_acre", "rate": 5}])
assert not validate_tenant_config(bad_unit).ok
ok = _cfg(services=[
{"id": "mow", "label": "Mow", "unit": "per_sqft", "rate": 0.02, "min_charge": 30},
{"id": "cleanup", "label": "Cleanup", "unit": "flat", "rate": 150}])
assert validate_tenant_config(ok).ok
def test_valid_qualifying_questions_pass():
cfg = copy.deepcopy(VALID)
cfg["qualifying_questions"] = [
{"id": "gate", "label": "Locked gate?", "type": "boolean", "surcharge": 10},
{"label": "Frequency", "type": "select",
"options": [{"label": "Weekly"}, {"label": "One-time", "surcharge": 25}]},
]
assert validate_tenant_config(cfg).ok
def test_qualifying_questions_reject_bad_values():
cfg = copy.deepcopy(VALID)
cfg["qualifying_questions"] = [
{"label": "", "type": "boolean"}, # empty label
{"label": "Dogs?", "type": "boolean", "surcharge": -5}, # negative surcharge
{"label": "Slope?", "type": "weird"}, # bad type
{"label": "Freq", "type": "select", "options": []}, # empty options
{"label": "Big", "type": "boolean", "surcharge": 999999}, # over ceiling
]
assert len(validate_tenant_config(cfg).errors) >= 5
def test_qualifying_questions_must_be_a_list():
cfg = copy.deepcopy(VALID)
cfg["qualifying_questions"] = {"not": "a list"}
assert not validate_tenant_config(cfg).ok
def test_email_from_off_authenticated_domain_warns_not_blocks():
cfg = copy.deepcopy(VALID)
cfg["email"] = {"from": "The Lawn Standard <quotes@thelawnstandard.net>"}
# No sending_domains configured → nothing to compare, no warning.
assert not any("spam" in w for w in validate_tenant_config(cfg).warnings)
# Authenticated set WITHOUT their domain → warns, but still publishable (ok).
r = validate_tenant_config(cfg, sending_domains={"mail.ourdomain.com"})
assert r.ok and any("thelawnstandard.net" in w and "spam" in w for w in r.warnings)
# from ON an authenticated domain → no warning.
r2 = validate_tenant_config(cfg, sending_domains={"thelawnstandard.net"})
assert not any("spam" in w for w in r2.warnings)