"""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 ", "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,", "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 "} # 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)