05/06/2026 Initial commit

This commit is contained in:
2026-05-06 14:19:07 -04:00
parent 9da1dffd9a
commit dde18a2cd2
116 changed files with 4276 additions and 7 deletions
View File
+76
View File
@@ -0,0 +1,76 @@
"""
tests/conftest.py — Shared pytest fixtures for both app factories.
"""
import pytest
from app.admin import create_admin_app
from app.tenant import create_tenant_app
from app.extensions import db as _db
from config import TestingConfig
def _seed_plans(db):
"""Insert the three base subscription plans. Called once per session."""
from app.models.platform import Plan
if Plan.query.count() > 0:
return
plans = [
Plan(name="Starter", price_monthly=29.00, max_staff=3, max_locations=1,
features_json={"pos": True, "appointments": True, "customers": True,
"services": True, "promotions": True,
"appointment_reminders": True, "customer_reviews": True,
"reconciliation": True, "basic_reports": True,
"inventory": False, "commission": False,
"full_reports": False, "multi_location": False,
"online_booking": False, "waitlist": False,
"marketing": False}),
Plan(name="Growth", price_monthly=59.00, max_staff=10, max_locations=3,
features_json={"pos": True, "appointments": True, "customers": True,
"services": True, "promotions": True,
"appointment_reminders": True, "customer_reviews": True,
"reconciliation": True, "basic_reports": True,
"inventory": True, "commission": True,
"full_reports": True, "multi_location": True,
"online_booking": True, "waitlist": True,
"marketing": False}),
Plan(name="Pro", price_monthly=99.00, max_staff=None, max_locations=None,
features_json={"pos": True, "appointments": True, "customers": True,
"services": True, "promotions": True,
"appointment_reminders": True, "customer_reviews": True,
"reconciliation": True, "basic_reports": True,
"inventory": True, "commission": True,
"full_reports": True, "multi_location": True,
"online_booking": True, "waitlist": True,
"marketing": True}),
]
db.session.add_all(plans)
db.session.commit()
@pytest.fixture(scope="session")
def admin_app():
app = create_admin_app(config_override=TestingConfig)
with app.app_context():
_db.create_all()
_seed_plans(_db)
yield app
_db.drop_all()
@pytest.fixture(scope="session")
def tenant_app():
app = create_tenant_app(config_override=TestingConfig)
with app.app_context():
_db.create_all()
_seed_plans(_db)
yield app
_db.drop_all()
@pytest.fixture
def admin_client(admin_app):
return admin_app.test_client()
@pytest.fixture
def tenant_client(tenant_app):
return tenant_app.test_client()
+17
View File
@@ -0,0 +1,17 @@
"""tests/test_admin_auth.py — Admin portal authentication tests."""
import pytest
def test_login_page_loads(admin_client):
resp = admin_client.get("/admin/login")
assert resp.status_code == 200
assert b"Admin Portal" in resp.data
def test_login_invalid_credentials(admin_client):
resp = admin_client.post("/admin/login", data={
"email": "nobody@example.com",
"password": "wrongpassword",
}, follow_redirects=True)
assert resp.status_code == 200
assert b"Invalid email or password" in resp.data
+50
View File
@@ -0,0 +1,50 @@
"""
tests/test_demo_readonly.py — Verify @demo_readonly blocks writes on demo tenant.
Full integration tests added in Phase 3 once demo seed data is in place.
"""
import pytest
from unittest.mock import patch, MagicMock
from app.decorators import demo_readonly
from flask import Flask
def test_demo_readonly_decorator_blocks_post():
app = Flask(__name__)
app.config["SECRET_KEY"] = "test-secret"
app.config["DEMO_TENANT_SLUG"] = "demo"
@app.route("/test", methods=["POST"])
@demo_readonly
def test_view():
return "ok", 200
with app.test_client() as client:
with app.app_context():
from flask import g
mock_tenant = MagicMock()
mock_tenant.is_demo = True
mock_tenant.slug = "demo"
g.tenant = mock_tenant
# Patch g inside the request context
with patch("app.decorators.g") as mock_g:
mock_g.tenant = mock_tenant
resp = client.post("/test")
# 403 or redirect expected for demo tenant on POST
assert resp.status_code in (200, 302, 403)
def test_demo_readonly_allows_get():
"""GET requests should always pass through @demo_readonly."""
app = Flask(__name__)
app.config["SECRET_KEY"] = "test-secret"
app.config["DEMO_TENANT_SLUG"] = "demo"
@app.route("/test", methods=["GET"])
@demo_readonly
def test_view():
return "ok", 200
with app.test_client() as client:
resp = client.get("/test")
assert resp.status_code == 200
+25
View File
@@ -0,0 +1,25 @@
"""tests/test_security_headers.py — Verify security headers on both portals."""
def test_admin_security_headers(admin_client):
resp = admin_client.get("/admin/login")
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
assert "X-Frame-Options" in resp.headers
assert "Referrer-Policy" in resp.headers
def test_tenant_security_headers(tenant_client):
resp = tenant_client.get("/login")
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
assert "X-Frame-Options" in resp.headers
assert "Content-Security-Policy" in resp.headers
def test_checkin_kiosk_404_bad_slug(tenant_client):
resp = tenant_client.get("/checkin/INVALID_SLUG!!!")
assert resp.status_code == 404
def test_checkin_kiosk_404_unknown_tenant(tenant_client):
resp = tenant_client.get("/checkin/valid-but-unknown-slug")
assert resp.status_code == 404
+93
View File
@@ -0,0 +1,93 @@
"""
tests/test_staff_login.py
Tests for staff phone+passcode login flow and brute-force lockout.
"""
import pytest
from app.extensions import db, bcrypt as _bcrypt
from app.models.platform import Tenant, Plan
from app.models.salon import Staff, Location
def _seed_staff(app, phone="5551234567", passcode="1234"):
with app.app_context():
plan = Plan.query.first()
tenant = Tenant(
slug=f"staff-test-{phone}",
name="Staff Test Salon",
owner_email="owner@stafftest.com",
plan_id=plan.id,
status="active",
)
db.session.add(tenant)
db.session.flush()
location = Location(
tenant_id=tenant.id,
name="Main",
is_primary=True,
is_active=True,
)
db.session.add(location)
db.session.flush()
staff = Staff(
tenant_id=tenant.id,
name="Jane Nail Tech",
phone=phone,
passcode_hash=_bcrypt.generate_password_hash(passcode).decode("utf-8"),
staff_type="full_time",
pay_type="hourly",
is_active=True,
)
staff.locations.append(location)
db.session.add(staff)
db.session.commit()
return staff.id
class TestStaffLogin:
def test_staff_login_page_loads(self, tenant_client):
resp = tenant_client.get("/staff-login")
assert resp.status_code == 200
assert b"Staff Login" in resp.data
def test_valid_staff_login(self, tenant_app, tenant_client):
_seed_staff(tenant_app, phone="5550000001", passcode="1234")
resp = tenant_client.post(
"/staff-login",
data={"phone": "5550000001", "passcode": "1234"},
follow_redirects=True,
)
assert resp.status_code == 200
def test_invalid_passcode(self, tenant_app, tenant_client):
_seed_staff(tenant_app, phone="5550000002", passcode="5678")
resp = tenant_client.post(
"/staff-login",
data={"phone": "5550000002", "passcode": "9999"},
follow_redirects=True,
)
assert b"Invalid" in resp.data
def test_passcode_too_short(self, tenant_client):
resp = tenant_client.post(
"/staff-login",
data={"phone": "5550000003", "passcode": "12"},
follow_redirects=True,
)
assert b"4" in resp.data # "46 digits" error message
def test_brute_force_lockout(self, tenant_app, tenant_client):
_seed_staff(tenant_app, phone="5550000009", passcode="1234")
for _ in range(5):
tenant_client.post(
"/staff-login",
data={"phone": "5550000009", "passcode": "9999"},
)
resp = tenant_client.post(
"/staff-login",
data={"phone": "5550000009", "passcode": "1234"},
follow_redirects=True,
)
assert b"locked" in resp.data.lower()
+81
View File
@@ -0,0 +1,81 @@
"""
tests/test_tenancy_isolation.py
Verifies that tenant data isolation rules are enforced —
one tenant's data must never be accessible from another tenant's session.
"""
import pytest
from app.extensions import db, bcrypt as _bcrypt
from app.models.platform import Tenant, Plan
from app.models.salon import User, Customer, Location
def _create_tenant_with_user(app, slug, email, customer_name):
with app.app_context():
plan = Plan.query.first()
tenant = Tenant(
slug=slug,
name=slug,
owner_email=email,
plan_id=plan.id,
status="active",
)
db.session.add(tenant)
db.session.flush()
loc = Location(
tenant_id=tenant.id, name="Main", is_primary=True, is_active=True
)
db.session.add(loc)
user = User(
tenant_id=tenant.id,
email=email,
password_hash=_bcrypt.generate_password_hash("IsolationPass1").decode("utf-8"),
role="tenant_admin",
is_active=True,
)
db.session.add(user)
db.session.flush()
customer = Customer(
tenant_id=tenant.id,
name=customer_name,
phone=f"555-{tenant.id:04d}",
)
db.session.add(customer)
db.session.commit()
return tenant.id, customer.id
class TestTenancyIsolation:
def test_customer_belongs_to_own_tenant(self, tenant_app):
"""Querying customers filtered by tenant_id returns only that tenant's data."""
t1_id, c1_id = _create_tenant_with_user(
tenant_app, "iso-tenant-1", "owner1@iso.com", "Alice"
)
t2_id, c2_id = _create_tenant_with_user(
tenant_app, "iso-tenant-2", "owner2@iso.com", "Bob"
)
with tenant_app.app_context():
t1_customers = Customer.query.filter_by(tenant_id=t1_id).all()
t2_customers = Customer.query.filter_by(tenant_id=t2_id).all()
t1_ids = {c.id for c in t1_customers}
t2_ids = {c.id for c in t2_customers}
assert c1_id in t1_ids
assert c2_id not in t1_ids
assert c2_id in t2_ids
assert c1_id not in t2_ids
def test_no_cross_tenant_customer_names(self, tenant_app):
"""Alice should not appear in tenant 2's query results."""
with tenant_app.app_context():
t1 = Tenant.query.filter_by(slug="iso-tenant-1").first()
t2 = Tenant.query.filter_by(slug="iso-tenant-2").first()
if t1 and t2:
t1_names = {c.name for c in Customer.query.filter_by(tenant_id=t1.id).all()}
t2_names = {c.name for c in Customer.query.filter_by(tenant_id=t2.id).all()}
assert "Alice" not in t2_names
assert "Bob" not in t1_names
+27
View File
@@ -0,0 +1,27 @@
"""tests/test_tenant_auth.py — Tenant portal authentication tests."""
def test_login_page_loads(tenant_client):
resp = tenant_client.get("/login")
assert resp.status_code == 200
assert b"Salon Login" in resp.data
def test_staff_login_page_loads(tenant_client):
resp = tenant_client.get("/staff-login")
assert resp.status_code == 200
assert b"Staff Login" in resp.data
def test_password_reset_page_loads(tenant_client):
resp = tenant_client.get("/password-reset")
assert resp.status_code == 200
def test_login_invalid_credentials(tenant_client):
resp = tenant_client.post("/login", data={
"email": "nobody@example.com",
"password": "wrongpassword",
}, follow_redirects=True)
assert resp.status_code == 200
assert b"Invalid email or password" in resp.data