05/06/2026 Initial commit
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 # "4–6 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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user