Spaces:
Running
Running
| """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) | |