add authenticated tenant-scoped sessions

This commit is contained in:
Marco0300
2026-09-02 17:45:57 +02:00
parent 8fa391a000
commit a52af65024
12 changed files with 309 additions and 115 deletions
+120 -79
View File
@@ -1,10 +1,14 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
import secrets
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
@@ -17,6 +21,23 @@ else:
ORGANIZATION_ID = "demo-tenant"
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
SESSION_DAYS = 7
PBKDF2_ITERATIONS = 300_000 # Development fallback: stdlib PBKDF2, not Argon2id.
MUTATING_ROLES = {"owner", "admin", "researcher"}
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
return digest.hex(), salt.hex()
def verify_password(password: str, encoded_hash: str, encoded_salt: str) -> bool:
try:
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(encoded_salt), PBKDF2_ITERATIONS).hex()
return secrets.compare_digest(digest, encoded_hash)
except (TypeError, ValueError):
return False
def connect(db_path: str) -> sqlite3.Connection:
@@ -24,6 +45,14 @@ def connect(db_path: str) -> sqlite3.Connection:
db.row_factory = sqlite3.Row
db.execute("PRAGMA foreign_keys = ON")
db.executescript(SCHEMA.read_text())
db.execute("INSERT OR IGNORE INTO organizations (id, name) VALUES (?, ?)", (ORGANIZATION_ID, "Demo organization"))
email, password = os.environ.get("BOOTSTRAP_ADMIN_EMAIL"), os.environ.get("BOOTSTRAP_ADMIN_PASSWORD")
if email and password:
existing = db.execute("SELECT id FROM users WHERE email = ?", (email.strip().lower(),)).fetchone()
if not existing:
password_hash, salt = hash_password(password)
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID, email.strip().lower(), password_hash, salt, "owner"))
db.commit()
return db
@@ -33,24 +62,18 @@ def row_json(row: sqlite3.Row) -> dict:
return result
def existing_businesses(db: sqlite3.Connection) -> list[dict]:
return [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY id", (ORGANIZATION_ID,))]
def suppression_rows(db: sqlite3.Connection) -> list[dict]:
return [dict(r) for r in db.execute("SELECT kind, value FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,))]
class ApiHandler(BaseHTTPRequestHandler):
server_version = "ProspectPlatform/0.1"
def send_json(self, status: int, payload: dict | list):
def send_json(self, status: int, payload: dict | list, extra_headers: dict[str, str] | None = None):
body = json.dumps(payload, sort_keys=True).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
self.send_header("Access-Control-Allow-Credentials", "true")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
for key, value in (extra_headers or {}).items(): self.send_header(key, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
@@ -63,116 +86,134 @@ class ApiHandler(BaseHTTPRequestHandler):
except (ValueError, json.JSONDecodeError):
return {}
def db(self):
return connect(self.server.db_path)
def db(self): return connect(getattr(self.server, "db_path"))
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
self.send_header("Access-Control-Allow-Credentials", "true")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def session_user(self, db: sqlite3.Connection):
cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", ""))
token = cookie.get("session")
if not token: return None
token_hash = hashlib.sha256(token.value.encode()).hexdigest()
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
return db.execute("SELECT u.id, u.email, u.role, u.organization_id FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?", (token_hash, now)).fetchone()
def require_auth(self, db):
user = self.session_user(db)
if not user:
self.send_json(401, {"error": "unauthorized"})
return None
return user
def auth_cookie(self, token: str, max_age: int) -> str:
return f"session={token}; Max-Age={max_age}; Path=/; HttpOnly; SameSite=Lax"
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/")
if path == "/api/v1/health/live":
return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID})
parsed = urlparse(self.path); path = parsed.path.rstrip("/")
if path == "/api/v1/health/live": return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID})
db = self.db()
try:
user = self.require_auth(db)
if not user: return
org = user["organization_id"]
if path == "/api/v1/auth/me": return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": org})
if path == "/api/v1/admin/users":
if user["role"] not in {"owner", "admin"}: return self.send_json(403, {"error": "forbidden"})
rows = db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id = ? ORDER BY id", (org,)).fetchall()
return self.send_json(200, {"items": [dict(r) for r in rows]})
if path == "/api/v1/dashboard/summary":
row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()[0]})
row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (org,)).fetchone()
return self.send_json(200, {"organization_id": org, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (org,)).fetchone()[0]})
if path == "/api/v1/businesses":
query = parse_qs(parsed.query).get("q", [""])[0].strip()
query = parse_qs(parsed.query).get("q", [""])[0].strip(); params = [org]
sql = "SELECT * FROM businesses WHERE organization_id = ?"
if query:
like = f"%{query}%"
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?) ORDER BY score DESC, id", (ORGANIZATION_ID, like, like, like)).fetchall()
else:
rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY score DESC, id", (ORGANIZATION_ID,)).fetchall()
return self.send_json(200, {"organization_id": ORGANIZATION_ID, "items": [row_json(r) for r in rows]})
like = f"%{query}%"; sql += " AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?)"; params += [like] * 3
rows = db.execute(sql + " ORDER BY score DESC, id", params).fetchall()
return self.send_json(200, {"organization_id": org, "items": [row_json(r) for r in rows]})
if path.startswith("/api/v1/businesses/"):
ident = path.rsplit("/", 1)[1]
if not ident.isdigit(): return self.send_json(404, {"error": "not_found"})
row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), ORGANIZATION_ID)).fetchone()
row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), org)).fetchone()
return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"})
return self.send_json(404, {"error": "not_found"})
finally:
db.close()
finally: db.close()
def do_POST(self):
path = urlparse(self.path).path.rstrip("/")
payload = self.read_json()
if path == "/api/v1/businesses": return self.create_business(payload)
if path == "/api/v1/suppressions": return self.create_suppression(payload)
if path == "/api/v1/imports/preview": return self.preview_import(payload)
return self.send_json(404, {"error": "not_found"})
def create_business(self, payload):
if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"})
business = normalize_business(payload)
if path == "/api/v1/auth/login": return self.login(self.read_json())
db = self.db()
try:
if is_suppressed(business, suppression_rows(db)): return self.send_json(409, {"error": "suppressed"})
identity_fields = [("website_domain", business["website_domain"]), ("email", business["email"]), ("phone", business["phone"])]
identity_fields = [(column, value) for column, value in identity_fields if value]
if identity_fields:
predicates = " OR ".join(f"{column} = ?" for column, _ in identity_fields)
values = [value for _, value in identity_fields]
if db.execute(f"SELECT id FROM businesses WHERE organization_id = ? AND ({predicates})", [ORGANIZATION_ID, *values]).fetchone():
return self.send_json(409, {"error": "duplicate"})
scored = score_business(business)
cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (ORGANIZATION_ID, business["name"], business["website"], business["website_domain"], business["email"], business["phone"], str(business.get("description", "")), scored["score"], scored["score_version"], json.dumps(scored["factors"]), scored["website_class"]))
db.commit()
return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ?", (cur.lastrowid,)).fetchone()))
user = self.require_auth(db)
if not user: return
if path == "/api/v1/auth/logout":
cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", "")); token = cookie.get("session")
if token: db.execute("DELETE FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.value.encode()).hexdigest(),))
db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "logout")); db.commit()
return self.send_json(200, {"ok": True}, {"Set-Cookie": self.auth_cookie("", 0)})
if user["role"] not in MUTATING_ROLES: return self.send_json(403, {"error": "forbidden"})
payload = self.read_json()
if path == "/api/v1/businesses": return self.create_business(payload, db, user["organization_id"])
if path == "/api/v1/suppressions": return self.create_suppression(payload, db, user["organization_id"])
if path == "/api/v1/imports/preview": return self.preview_import(payload, db, user["organization_id"])
return self.send_json(404, {"error": "not_found"})
finally: db.close()
def create_suppression(self, payload):
def login(self, payload):
email = str(payload.get("email", "")).strip().lower(); password = str(payload.get("password", "")); db = self.db()
try:
user = db.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
if not user or not verify_password(password, user["password_hash"], user["password_salt"]): return self.send_json(401, {"error": "invalid_credentials"})
token = secrets.token_urlsafe(32); expires = datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
db.execute("INSERT INTO sessions (user_id,token_hash,expires_at) VALUES (?,?,?)", (user["id"], hashlib.sha256(token.encode()).hexdigest(), expires.replace(microsecond=0).isoformat()))
db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "login")); db.commit()
return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": user["organization_id"]}, {"Set-Cookie": self.auth_cookie(token, int(timedelta(days=SESSION_DAYS).total_seconds()))})
finally: db.close()
def create_business(self, payload, db, org):
if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"})
business = normalize_business(payload); suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]
if is_suppressed(business, suppressions): return self.send_json(409, {"error": "suppressed"})
fields = [(c, business[c]) for c in ("website_domain", "email", "phone") if business[c]]
if fields and db.execute("SELECT id FROM businesses WHERE organization_id = ? AND (" + " OR ".join(f"{c} = ?" for c, _ in fields) + ")", [org] + [v for _, v in fields]).fetchone(): return self.send_json(409, {"error": "duplicate"})
scored = score_business(business); cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (org,business["name"],business["website"],business["website_domain"],business["email"],business["phone"],str(business.get("description", "")),scored["score"],scored["score_version"],json.dumps(scored["factors"]),scored["website_class"]))
db.commit(); return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (cur.lastrowid, org)).fetchone()))
def create_suppression(self, payload, db, org):
kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower()
if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"})
db = self.db()
try:
try:
db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (ORGANIZATION_ID, kind, value)); db.commit()
except sqlite3.IntegrityError: pass
row = db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (ORGANIZATION_ID, kind, value)).fetchone()
return self.send_json(201, dict(row))
finally: db.close()
try: db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (org, kind, value)); db.commit()
except sqlite3.IntegrityError: pass
return self.send_json(201, dict(db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (org, kind, value)).fetchone()))
def preview_import(self, payload):
def preview_import(self, payload, db, org):
rows = payload.get("rows", [])
if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"})
normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()])
db = self.db()
try:
suppressions = suppression_rows(db); existing = existing_businesses(db); seen = set()
accepted, duplicate, suppressed = [], 0, 0
for b in normalized:
key = deduplication_key(b)
if is_suppressed(b, suppressions): suppressed += 1
elif (key in {deduplication_key(x) for x in existing} or key in seen): duplicate += 1
else: seen.add(key); accepted.append(b)
return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted})
finally: db.close()
suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]; existing = [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ?", (org,))]; seen = set(); accepted, duplicate, suppressed = [], 0, 0
existing_keys = {deduplication_key(x) for x in existing}
for b in normalized:
key = deduplication_key(b)
if is_suppressed(b, suppressions): suppressed += 1
elif key in existing_keys or key in seen: duplicate += 1
else: seen.add(key); accepted.append(b)
return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted})
def log_message(self, *_): pass
def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"):
server = ThreadingHTTPServer((host, port), ApiHandler)
server.db_path = db_path
connect(db_path).close()
return server
server = ThreadingHTTPServer((host, port), ApiHandler); setattr(server, "db_path", db_path); connect(db_path).close(); return server
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Prospect Platform API")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000")))
parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db"))
args = parser.parse_args()
server = create_server(args.host, args.port, args.db)
print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True)
parser = argparse.ArgumentParser(description="Prospect Platform API"); parser.add_argument("--host", default="127.0.0.1"); parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000"))); parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db")); args = parser.parse_args(); server = create_server(args.host, args.port, args.db); print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True)
try: server.serve_forever()
except KeyboardInterrupt: pass
finally: server.server_close()
+38
View File
@@ -1,4 +1,41 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL REFERENCES organizations(id),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('viewer','owner','admin','researcher')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT REFERENCES organizations(id),
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(organization_id);
CREATE TABLE IF NOT EXISTS businesses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL,
@@ -18,6 +55,7 @@ CREATE INDEX IF NOT EXISTS idx_businesses_org ON businesses(organization_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_domain ON businesses(organization_id, website_domain) WHERE website_domain <> '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_email ON businesses(organization_id, email) WHERE email <> '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_business_phone ON businesses(organization_id, phone) WHERE phone <> '';
CREATE TABLE IF NOT EXISTS suppressions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL,
+56 -5
View File
@@ -1,31 +1,82 @@
import json
import os
import sqlite3
import threading
import unittest
from http.client import HTTPConnection
from tempfile import TemporaryDirectory
from app.main import create_server
from app.main import create_server, hash_password
class ApiSmokeTests(unittest.TestCase):
def setUp(self):
self.tmp = TemporaryDirectory()
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/test.db")
self.old_env = {key: os.environ.get(key) for key in ("BOOTSTRAP_ADMIN_EMAIL", "BOOTSTRAP_ADMIN_PASSWORD")}
os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "owner@example.test"
os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "development-password"
self.db_path = self.tmp.name + "/test.db"
self.server = create_server("127.0.0.1", 0, self.db_path)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
self.cookie = None
status, _ = self.request("POST", "/api/v1/auth/login", {"email": "owner@example.test", "password": "development-password"})
self.assertEqual(status, 200)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
for key, value in self.old_env.items():
if value is None: os.environ.pop(key, None)
else: os.environ[key] = value
self.tmp.cleanup()
def request(self, method, path, payload=None):
def request(self, method, path, payload=None, cookie=True):
body = json.dumps(payload).encode() if payload is not None else None
self.conn.request(method, path, body, {"Content-Type": "application/json"} if body else {})
headers = {"Content-Type": "application/json"} if body else {}
if cookie and self.cookie: headers["Cookie"] = self.cookie
self.conn.request(method, path, body, headers)
response = self.conn.getresponse()
return response.status, json.loads(response.read())
set_cookie = response.getheader("Set-Cookie")
if set_cookie and "session=" in set_cookie:
self.cookie = set_cookie.split(";", 1)[0]
return response.status, json.loads(response.read() or b"{}")
def test_auth_login_me_logout_and_protected_route(self):
self.assertEqual(self.request("GET", "/api/v1/auth/me")[0], 200)
self.assertEqual(self.request("POST", "/api/v1/auth/logout")[0], 200)
self.assertEqual(self.request("GET", "/api/v1/dashboard/summary")[0], 401)
def test_viewer_cannot_mutate(self):
password_hash, salt = hash_password("viewer-password")
db = sqlite3.connect(self.db_path)
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("demo-tenant", "viewer@example.test", password_hash, salt, "viewer"))
db.commit(); db.close()
self.cookie = None
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "viewer@example.test", "password": "viewer-password"})[0], 200)
self.assertEqual(self.request("POST", "/api/v1/businesses", {"name": "Nope"})[0], 403)
def test_cross_organization_businesses_are_isolated(self):
password_hash, salt = hash_password("other-password")
db = sqlite3.connect(self.db_path)
db.execute("INSERT INTO organizations (id,name) VALUES (?,?)", ("other-tenant", "Other"))
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("other-tenant", "other@example.test", password_hash, salt, "owner"))
db.commit(); db.close()
self.cookie = None
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other@example.test", "password": "other-password"})[0], 200)
self.assertEqual(self.request("POST", "/api/v1/businesses", {"name": "Other Co", "website": "https://other.test"})[0], 201)
self.assertEqual(self.request("GET", "/api/v1/businesses")[1]["organization_id"], "other-tenant")
self.cookie = None
self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "owner@example.test", "password": "development-password"})[0], 200)
status, businesses = self.request("GET", "/api/v1/businesses")
self.assertEqual(status, 200)
self.assertEqual(businesses["items"], [])
def test_missing_auth_is_rejected(self):
self.cookie = None
self.assertEqual(self.request("GET", "/api/v1/businesses", cookie=False)[0], 401)
def test_health_create_get_summary_and_import_preview(self):
self.assertEqual(self.request("GET", "/api/v1/health/live")[0], 200)