Harden PKI license verification, database seat reconciliation, and binary packaging
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m27s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m27s
This commit is contained in:
@@ -60,11 +60,13 @@ def write_checksum_file(filename, digest, binary_filename):
|
|||||||
|
|
||||||
|
|
||||||
def build_pyinstaller_binary(script_path, binary_name):
|
def build_pyinstaller_binary(script_path, binary_name):
|
||||||
print(f"[*] Compiling {binary_name} with PyInstaller...")
|
print(f"[*] Compiling {binary_name} with PyInstaller (Bytecode optimized & stripped)...")
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable, "-m", "PyInstaller",
|
sys.executable, "-m", "PyInstaller",
|
||||||
"--onefile",
|
"--onefile",
|
||||||
"--clean",
|
"--clean",
|
||||||
|
"--strip",
|
||||||
|
"--optimize", "2",
|
||||||
"--distpath", DIST_DIR,
|
"--distpath", DIST_DIR,
|
||||||
"--workpath", os.path.join(BUILD_TEMP, f"work_{binary_name}"),
|
"--workpath", os.path.join(BUILD_TEMP, f"work_{binary_name}"),
|
||||||
"--specpath", os.path.join(BUILD_TEMP, f"spec_{binary_name}"),
|
"--specpath", os.path.join(BUILD_TEMP, f"spec_{binary_name}"),
|
||||||
|
|||||||
+16
-3
@@ -1313,8 +1313,10 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
|
|||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=500, detail="License configuration not initialized")
|
raise HTTPException(status_code=500, detail="License configuration not initialized")
|
||||||
|
|
||||||
expected_secret, max_seats = row[0], row[1]
|
expected_secret = row[0]
|
||||||
license_status = row[2] if len(row) > 2 and row[2] else ("active" if max_seats > 0 else "unlicensed")
|
lic_summary = LicenseManager.get_license_summary(SERVER_STATE, db_path=db_path)
|
||||||
|
max_seats = lic_summary.get("max_seats", 0)
|
||||||
|
license_status = lic_summary.get("status", "UNLICENSED").lower()
|
||||||
if max_seats <= 0 or license_status in ("unlicensed", "revoked", "expired"):
|
if max_seats <= 0 or license_status in ("unlicensed", "revoked", "expired"):
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=403, detail="Server is unlicensed. Import an activated .lic file via the WebUI before enrolling edge clients.")
|
raise HTTPException(status_code=403, detail="Server is unlicensed. Import an activated .lic file via the WebUI before enrolling edge clients.")
|
||||||
@@ -1893,9 +1895,20 @@ def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(re
|
|||||||
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
|
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
|
||||||
ca_file = os.path.join(cert_dir, "ca.crt")
|
ca_file = os.path.join(cert_dir, "ca.crt")
|
||||||
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
|
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
|
upstream_pem = req.upstream_ca.strip() if req.upstream_ca else None
|
||||||
|
if not upstream_pem and os.path.exists(upstream_file):
|
||||||
|
try:
|
||||||
|
with open(upstream_file, "r", encoding="utf-8") as f:
|
||||||
|
upstream_pem = f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
upstream_pem = None
|
||||||
|
|
||||||
|
if upstream_pem:
|
||||||
|
if not enrollment.verify_certificate_signature(req.ca_cert, upstream_pem):
|
||||||
|
raise HTTPException(status_code=400, detail="Cryptographic verification failed: Sub-CA certificate was not signed by the upstream Master Authority.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
info = enrollment.extract_license_info_from_cert(req.ca_cert)
|
info = enrollment.extract_license_info_from_cert(req.ca_cert, upstream_ca_pem=upstream_pem)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Failed to parse X.509 certificate: {e}")
|
raise HTTPException(status_code=400, detail=f"Failed to parse X.509 certificate: {e}")
|
||||||
|
|
||||||
|
|||||||
+44
-9
@@ -42,15 +42,23 @@ class LicenseManager:
|
|||||||
def get_active_licensed_features(db_path: Optional[str] = None, server_state: Optional[Dict[str, Any]] = None) -> List[str]:
|
def get_active_licensed_features(db_path: Optional[str] = None, server_state: Optional[Dict[str, Any]] = None) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Returns the list of all active enterprise capability identifiers
|
Returns the list of all active enterprise capability identifiers
|
||||||
authorized either via X.509 certificate extensions or the persistent database license config.
|
authorized either via verified X.509 certificate extensions or the persistent database license config.
|
||||||
"""
|
"""
|
||||||
features = set()
|
features = set()
|
||||||
state = server_state or {}
|
state = server_state or {}
|
||||||
|
cert_dir = state.get("config", {}).get("cert_dir", "certs")
|
||||||
|
upstream_pem = None
|
||||||
|
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
|
if os.path.exists(upstream_file):
|
||||||
|
try:
|
||||||
|
with open(upstream_file, "r", encoding="utf-8") as f:
|
||||||
|
upstream_pem = f.read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 1. Check in-memory / active Subordinate CA certificate
|
# 1. Check in-memory / active Subordinate CA certificate
|
||||||
ca_pem = state.get("ca_cert_pem")
|
ca_pem = state.get("ca_cert_pem")
|
||||||
if not ca_pem:
|
if not ca_pem:
|
||||||
cert_dir = state.get("config", {}).get("cert_dir", "certs")
|
|
||||||
ca_file = os.path.join(cert_dir, "ca.crt")
|
ca_file = os.path.join(cert_dir, "ca.crt")
|
||||||
if os.path.exists(ca_file):
|
if os.path.exists(ca_file):
|
||||||
try:
|
try:
|
||||||
@@ -59,9 +67,11 @@ class LicenseManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
cert_is_verified = False
|
||||||
if ca_pem:
|
if ca_pem:
|
||||||
try:
|
try:
|
||||||
info = enrollment.extract_license_info_from_cert(ca_pem)
|
info = enrollment.extract_license_info_from_cert(ca_pem, upstream_ca_pem=upstream_pem)
|
||||||
|
cert_is_verified = info.get("signature_verified", False) or not info.get("is_self_signed", False)
|
||||||
for feat in info.get("features", []):
|
for feat in info.get("features", []):
|
||||||
if feat:
|
if feat:
|
||||||
features.add(str(feat).strip())
|
features.add(str(feat).strip())
|
||||||
@@ -105,14 +115,23 @@ class LicenseManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_license_summary(server_state: Optional[Dict[str, Any]] = None, db_path: Optional[str] = None) -> Dict[str, Any]:
|
def get_license_summary(server_state: Optional[Dict[str, Any]] = None, db_path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Inspects certs/ca.crt, database quotas, and returns comprehensive licensing status."""
|
"""Inspects certs/ca.crt, database quotas, verifies cryptographic signatures, and returns comprehensive licensing status."""
|
||||||
state = server_state or {}
|
state = server_state or {}
|
||||||
config = state.get("config", {})
|
config = state.get("config", {})
|
||||||
cert_dir = config.get("cert_dir", "certs")
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
upstream_path = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
target_db = db_path or config.get("db_path", LicenseManager.DEFAULT_DB_FILE)
|
target_db = db_path or config.get("db_path", LicenseManager.DEFAULT_DB_FILE)
|
||||||
|
|
||||||
max_seats = config.get("max_seats", 0)
|
upstream_pem = None
|
||||||
|
if os.path.exists(upstream_path):
|
||||||
|
try:
|
||||||
|
with open(upstream_path, "r", encoding="utf-8") as f:
|
||||||
|
upstream_pem = f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db_max_seats = config.get("max_seats", 0)
|
||||||
license_status_db = "unlicensed"
|
license_status_db = "unlicensed"
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(target_db)
|
conn = sqlite3.connect(target_db)
|
||||||
@@ -121,7 +140,7 @@ class LicenseManager:
|
|||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
if row:
|
if row:
|
||||||
if row[0] is not None:
|
if row[0] is not None:
|
||||||
max_seats = row[0]
|
db_max_seats = row[0]
|
||||||
if len(row) > 1 and row[1]:
|
if len(row) > 1 and row[1]:
|
||||||
license_status_db = row[1]
|
license_status_db = row[1]
|
||||||
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
|
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
|
||||||
@@ -136,18 +155,33 @@ class LicenseManager:
|
|||||||
csr_path = os.path.join(cert_dir, "ca.csr")
|
csr_path = os.path.join(cert_dir, "ca.csr")
|
||||||
csr_exists = os.path.exists(csr_path)
|
csr_exists = os.path.exists(csr_path)
|
||||||
|
|
||||||
|
cert_seats = None
|
||||||
if os.path.exists(ca_path):
|
if os.path.exists(ca_path):
|
||||||
try:
|
try:
|
||||||
with open(ca_path, "r", encoding="utf-8") as f:
|
with open(ca_path, "r", encoding="utf-8") as f:
|
||||||
ca_pem = f.read()
|
ca_pem = f.read()
|
||||||
info = enrollment.extract_license_info_from_cert(ca_pem)
|
info = enrollment.extract_license_info_from_cert(ca_pem, upstream_ca_pem=upstream_pem)
|
||||||
if info.get("seats"):
|
if info.get("seats") is not None:
|
||||||
max_seats = info["seats"]
|
cert_seats = info["seats"]
|
||||||
except Exception:
|
except Exception:
|
||||||
info = {"valid": False}
|
info = {"valid": False}
|
||||||
else:
|
else:
|
||||||
info = {"valid": False, "is_self_signed": False, "is_expired": False, "days_remaining": 0}
|
info = {"valid": False, "is_self_signed": False, "is_expired": False, "days_remaining": 0}
|
||||||
|
|
||||||
|
# Anti-tampering reconciliation: If database max_seats diverged from certified quota, enforce certified quota and update DB
|
||||||
|
if cert_seats is not None:
|
||||||
|
max_seats = cert_seats
|
||||||
|
if db_max_seats != cert_seats and os.path.exists(target_db):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(target_db)
|
||||||
|
conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (cert_seats,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
max_seats = db_max_seats
|
||||||
|
|
||||||
license_id = info.get("license_id") or "UNLICENSED"
|
license_id = info.get("license_id") or "UNLICENSED"
|
||||||
issuer_url = info.get("issuer_url") or config.get("license_server_url", "https://logar.eibl.tech")
|
issuer_url = info.get("issuer_url") or config.get("license_server_url", "https://logar.eibl.tech")
|
||||||
|
|
||||||
@@ -176,6 +210,7 @@ class LicenseManager:
|
|||||||
"issuer_cn": info.get("issuer_cn", "N/A"),
|
"issuer_cn": info.get("issuer_cn", "N/A"),
|
||||||
"subject_cn": info.get("subject_cn", "N/A"),
|
"subject_cn": info.get("subject_cn", "N/A"),
|
||||||
"is_self_signed": info.get("is_self_signed", False),
|
"is_self_signed": info.get("is_self_signed", False),
|
||||||
|
"signature_verified": info.get("signature_verified", False),
|
||||||
"not_valid_before": info.get("not_valid_before", "N/A"),
|
"not_valid_before": info.get("not_valid_before", "N/A"),
|
||||||
"not_valid_after": info.get("not_valid_after", "N/A"),
|
"not_valid_after": info.get("not_valid_after", "N/A"),
|
||||||
"days_remaining": info.get("days_remaining", 0),
|
"days_remaining": info.get("days_remaining", 0),
|
||||||
|
|||||||
@@ -408,10 +408,30 @@ def check_and_renew_hub_pki(
|
|||||||
return False, False
|
return False, False
|
||||||
|
|
||||||
|
|
||||||
def extract_license_info_from_cert(cert_pem: str) -> Dict[str, Any]:
|
def verify_certificate_signature(cert_pem: Any, issuer_ca_pem: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Cryptographically verifies that cert_pem was signed by the private key corresponding to issuer_ca_pem.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) if isinstance(cert_pem, str) else cert_pem
|
||||||
|
issuer = x509.load_pem_x509_certificate(issuer_ca_pem.encode("utf-8")) if isinstance(issuer_ca_pem, str) else issuer_ca_pem
|
||||||
|
issuer.public_key().verify(
|
||||||
|
cert.signature,
|
||||||
|
cert.tbs_certificate_bytes,
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
cert.signature_hash_algorithm
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_license_info_from_cert(cert_pem: str, upstream_ca_pem: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extracts embedded licensing attributes, validity, and metadata from a PEM X.509 certificate.
|
Extracts embedded licensing attributes, validity, and metadata from a PEM X.509 certificate.
|
||||||
Supports decoding seat quota and license IDs embedded via LOGAR custom enterprise OIDs.
|
Supports decoding seat quota and license IDs embedded via LOGAR custom enterprise OIDs.
|
||||||
|
Cryptographically verifies the signature if upstream_ca_pem is provided.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8"))
|
cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8"))
|
||||||
@@ -490,6 +510,12 @@ def extract_license_info_from_cert(cert_pem: str) -> Dict[str, Any]:
|
|||||||
break
|
break
|
||||||
|
|
||||||
is_self_signed = (cert.subject == cert.issuer)
|
is_self_signed = (cert.subject == cert.issuer)
|
||||||
|
signature_verified = False
|
||||||
|
|
||||||
|
if upstream_ca_pem:
|
||||||
|
signature_verified = verify_certificate_signature(cert, upstream_ca_pem)
|
||||||
|
elif is_self_signed:
|
||||||
|
signature_verified = verify_certificate_signature(cert, cert)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
@@ -501,6 +527,7 @@ def extract_license_info_from_cert(cert_pem: str) -> Dict[str, Any]:
|
|||||||
"issuer_cn": issuer_cn,
|
"issuer_cn": issuer_cn,
|
||||||
"is_ca": is_ca,
|
"is_ca": is_ca,
|
||||||
"is_self_signed": is_self_signed,
|
"is_self_signed": is_self_signed,
|
||||||
|
"signature_verified": signature_verified,
|
||||||
"fingerprint": cert.fingerprint(hashes.SHA256()).hex().upper(),
|
"fingerprint": cert.fingerprint(hashes.SHA256()).hex().upper(),
|
||||||
"not_valid_before": not_before.isoformat(),
|
"not_valid_before": not_before.isoformat(),
|
||||||
"not_valid_after": not_after.isoformat(),
|
"not_valid_after": not_after.isoformat(),
|
||||||
|
|||||||
@@ -205,6 +205,94 @@ class TestServerLicensingAndWebUI(unittest.TestCase):
|
|||||||
self.assertEqual(info["seats"], 20)
|
self.assertEqual(info["seats"], 20)
|
||||||
self.assertEqual(info["license_id"], "LIC-ACT-20")
|
self.assertEqual(info["license_id"], "LIC-ACT-20")
|
||||||
|
|
||||||
|
def test_signature_verification_and_anti_forgery(self):
|
||||||
|
"""Verifies cryptographic signature validation against master root CA and detection of forged certificates."""
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
sub_pem, root_pem = _create_mock_signed_license_cert(csr_pem, seats=30, license_id="LIC-VALID-30")
|
||||||
|
|
||||||
|
# 1. Valid signature against genuine root CA
|
||||||
|
self.assertTrue(enrollment.verify_certificate_signature(sub_pem, root_pem))
|
||||||
|
info_valid = enrollment.extract_license_info_from_cert(sub_pem, upstream_ca_pem=root_pem)
|
||||||
|
self.assertTrue(info_valid["signature_verified"])
|
||||||
|
self.assertEqual(info_valid["seats"], 30)
|
||||||
|
|
||||||
|
# 2. Fake / Rogue Root CA trying to verify the same cert
|
||||||
|
fake_csr, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
_, fake_root_pem = _create_mock_signed_license_cert(fake_csr, seats=999, license_id="LIC-FAKE")
|
||||||
|
self.assertFalse(enrollment.verify_certificate_signature(sub_pem, fake_root_pem))
|
||||||
|
info_invalid = enrollment.extract_license_info_from_cert(sub_pem, upstream_ca_pem=fake_root_pem)
|
||||||
|
self.assertFalse(info_invalid["signature_verified"])
|
||||||
|
|
||||||
|
def test_database_tampering_seat_reconciliation(self):
|
||||||
|
"""Verifies that direct DB manipulation of max_seats is reconciled against the certified X.509 quota."""
|
||||||
|
from license_manager import LicenseManager
|
||||||
|
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
sub_pem, root_pem = _create_mock_signed_license_cert(csr_pem, seats=25, license_id="LIC-SEAT-25")
|
||||||
|
|
||||||
|
# Save certified ca.crt and upstream_ca.crt
|
||||||
|
ca_path = os.path.join(self.server_cert_dir, "ca.crt")
|
||||||
|
with open(ca_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(sub_pem)
|
||||||
|
with open(os.path.join(self.server_cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(root_pem)
|
||||||
|
|
||||||
|
Server.init_db(self.server_db, max_seats=25)
|
||||||
|
server_state = {
|
||||||
|
"config": {
|
||||||
|
"db_path": self.server_db,
|
||||||
|
"cert_dir": self.server_cert_dir,
|
||||||
|
"max_seats": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attacker attempts direct SQLite manipulation: SET max_seats = 99999
|
||||||
|
conn = sqlite3.connect(self.server_db)
|
||||||
|
conn.execute("UPDATE license_config SET max_seats = 99999 WHERE id = 1")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# LicenseManager inspects state and auto-reconciles max_seats to certified 25
|
||||||
|
summary = LicenseManager.get_license_summary(server_state=server_state, db_path=self.server_db)
|
||||||
|
self.assertEqual(summary["max_seats"], 25)
|
||||||
|
self.assertTrue(summary["signature_verified"])
|
||||||
|
|
||||||
|
# Check DB was reconciled
|
||||||
|
conn = sqlite3.connect(self.server_db)
|
||||||
|
row = conn.execute("SELECT max_seats FROM license_config WHERE id = 1").fetchone()
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(row[0], 25)
|
||||||
|
|
||||||
|
def test_import_license_signature_validation(self):
|
||||||
|
"""Verifies that /api/license/import rejects certificates not signed by the upstream authority."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
sub_pem, root_pem = _create_mock_signed_license_cert(csr_pem, seats=50, license_id="LIC-MOCK-50")
|
||||||
|
|
||||||
|
# Generate mismatched root CA
|
||||||
|
fake_csr, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
_, rogue_root_pem = _create_mock_signed_license_cert(fake_csr, seats=10, license_id="LIC-ROGUE")
|
||||||
|
|
||||||
|
Server.init_db(self.server_db, max_seats=5)
|
||||||
|
Server.SERVER_STATE["config"] = {
|
||||||
|
"db_path": self.server_db,
|
||||||
|
"cert_dir": self.server_cert_dir,
|
||||||
|
"max_seats": 5
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attempt to import sub_pem with mismatched upstream_ca -> must raise HTTPException 400
|
||||||
|
req_bad = Server.LicenseImportRequest(ca_cert=sub_pem, upstream_ca=rogue_root_pem)
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
Server.import_signed_license(req_bad, auth_user="admin")
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
self.assertIn("Cryptographic verification failed", ctx.exception.detail)
|
||||||
|
|
||||||
|
# Import sub_pem with valid matching upstream_ca -> must succeed
|
||||||
|
req_good = Server.LicenseImportRequest(ca_cert=sub_pem, upstream_ca=root_pem)
|
||||||
|
res = Server.import_signed_license(req_good, auth_user="admin")
|
||||||
|
self.assertEqual(res["status"], "success")
|
||||||
|
self.assertEqual(res["seats"], 50)
|
||||||
|
|
||||||
def test_webui_rendering(self):
|
def test_webui_rendering(self):
|
||||||
"""Verifies that the WebUI HTML contains key components, stats, and download triggers."""
|
"""Verifies that the WebUI HTML contains key components, stats, and download triggers."""
|
||||||
Server.init_db(self.server_db, max_seats=10)
|
Server.init_db(self.server_db, max_seats=10)
|
||||||
@@ -231,3 +319,4 @@ class TestServerLicensingAndWebUI(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user