- 公司账号创建改为随机一次性初始密码,只在创建响应中显示一次, 密码保证不等于用户名;删除「用户名即初始密码」兼容分支,继续 强制首次登录改密。 - 跨公司相同字节文件上传只返回通用重复状态:不再返回其他公司的 原批次 ID、银行、模板、期间、交易数或诊断;同公司重复上传的 幂等摘要保持可用。 - 补充服务端回归测试,覆盖同公司与跨公司两个分支及随机密码; 完整测试 85 项全绿,node --check 通过。
544 lines
22 KiB
Python
544 lines
22 KiB
Python
"""HTTP integration tests for authentication, RBAC and tenant isolation.
|
|
|
|
Spins up a real ``ThreadingHTTPServer`` with a temp database/storage and
|
|
drives it with stdlib ``http.client`` (cookies handled by hand). The server
|
|
module reads ``APP_DB_PATH`` / ``APP_STORAGE_DIR`` from module globals at
|
|
request time, so tests patch them per class.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from http.client import HTTPConnection
|
|
from http.cookies import SimpleCookie
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
|
|
from bank_importer import auth
|
|
from bank_importer.db import connect, migrate, utc_now
|
|
|
|
import server
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SAMPLES = ROOT / "流水模板"
|
|
CCB_SAMPLE = SAMPLES / "中国建设银行账户流水.xls"
|
|
CITIC_SAMPLE = SAMPLES / "中信银行账户流水.xlsx"
|
|
|
|
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
|
ADMIN_PASSWORD = "AdminPass123"
|
|
CASHIER_A_PASSWORD = "CashierA123"
|
|
|
|
|
|
class Client:
|
|
"""Minimal HTTP client with a cookie jar."""
|
|
|
|
def __init__(self, host: str, port: int) -> None:
|
|
self.host = host
|
|
self.port = port
|
|
self.cookies: dict[str, str] = {}
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
body: bytes | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
) -> tuple[int, dict[str, str], bytes]:
|
|
connection = HTTPConnection(self.host, self.port)
|
|
request_headers = dict(headers or {})
|
|
if self.cookies:
|
|
request_headers["Cookie"] = "; ".join(
|
|
f"{key}={value}" for key, value in self.cookies.items()
|
|
)
|
|
connection.request(method, path, body=body, headers=request_headers)
|
|
response = connection.getresponse()
|
|
data = response.read()
|
|
response_headers = {key.lower(): value for key, value in response.getheaders()}
|
|
set_cookie = response_headers.get("set-cookie")
|
|
if set_cookie:
|
|
cookie = SimpleCookie()
|
|
cookie.load(set_cookie)
|
|
for key, morsel in cookie.items():
|
|
if morsel.value:
|
|
self.cookies[key] = morsel.value
|
|
else:
|
|
self.cookies.pop(key, None)
|
|
status = response.status
|
|
connection.close()
|
|
return status, response_headers, data
|
|
|
|
def get(self, path: str) -> tuple[int, dict[str, str], bytes]:
|
|
return self.request("GET", path)
|
|
|
|
def post_json(self, path: str, payload: dict) -> tuple[int, dict, bytes]:
|
|
return self.request(
|
|
"POST",
|
|
path,
|
|
body=json.dumps(payload).encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
def post_multipart(
|
|
self, path: str, fields: dict[str, str], filename: str, content: bytes
|
|
) -> tuple[int, dict, bytes]:
|
|
boundary = "----cwtestboundary7f3a9c1e"
|
|
parts: list[bytes] = []
|
|
for name, value in fields.items():
|
|
parts.append(
|
|
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
|
|
)
|
|
parts.append(
|
|
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
|
"Content-Type: application/octet-stream\r\n\r\n".encode()
|
|
+ content
|
|
+ b"\r\n"
|
|
)
|
|
parts.append(f"--{boundary}--\r\n".encode())
|
|
return self.request(
|
|
"POST",
|
|
path,
|
|
body=b"".join(parts),
|
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
|
)
|
|
|
|
|
|
def as_json(data: bytes) -> dict:
|
|
return json.loads(data.decode("utf-8"))
|
|
|
|
|
|
class ServerAuthMatrixTests(unittest.TestCase):
|
|
"""One live server; setUpClass builds the shared fixture via the API."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.temp_dir = tempfile.TemporaryDirectory()
|
|
root = Path(cls.temp_dir.name)
|
|
cls.db_path = root / "app.db"
|
|
cls.storage = root / "files"
|
|
|
|
cls._old_db_path = server.DB_PATH
|
|
cls._old_storage = server.STORAGE_DIR
|
|
server.DB_PATH = cls.db_path
|
|
server.STORAGE_DIR = cls.storage
|
|
|
|
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
|
connection = connect(cls.db_path)
|
|
migrate(connection)
|
|
generated = server.ensure_bootstrap_admin(connection)
|
|
assert generated is None, "env password set, nothing should be generated"
|
|
connection.close()
|
|
|
|
class QuietHandler(server.AppHandler):
|
|
def log_message(self, *args) -> None: # silence per-request logs
|
|
pass
|
|
|
|
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
|
cls.port = cls.httpd.server_address[1]
|
|
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
|
cls.thread.start()
|
|
|
|
cls.known_passwords = {BOOTSTRAP_PASSWORD, ADMIN_PASSWORD, CASHIER_A_PASSWORD}
|
|
|
|
# --- Admin bootstrap: must_change_password gate, then change. ---
|
|
cls.admin = Client("127.0.0.1", cls.port)
|
|
status, _, data = cls.admin.post_json(
|
|
"/api/login",
|
|
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
|
)
|
|
assert status == 200, data
|
|
assert as_json(data)["must_change_password"] is True
|
|
status, _, data = cls.admin.get("/api/batches")
|
|
assert status == 403, "must_change_password must block API access"
|
|
status, _, data = cls.admin.post_json(
|
|
"/api/password/change",
|
|
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
|
)
|
|
assert status == 200, data
|
|
status, _, _ = cls.admin.get("/api/batches")
|
|
assert status == 200
|
|
|
|
# --- Companies A and B. ---
|
|
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "甲公司"})
|
|
assert status == 200, data
|
|
cls.company_a = as_json(data)["company_id"]
|
|
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
|
|
assert status == 200, data
|
|
cls.company_b = as_json(data)["company_id"]
|
|
|
|
# --- Company user A: random one-time initial password, forced change. ---
|
|
status, _, data = cls.admin.post_json(
|
|
"/api/admin/users", {"username": "cashier-a", "company_id": cls.company_a}
|
|
)
|
|
assert status == 200, data
|
|
payload = as_json(data)
|
|
# Security: the initial password is random and never equals the username.
|
|
assert payload["initial_password"] != "cashier-a"
|
|
assert len(payload["initial_password"]) >= 12
|
|
cls.initial_password_a = payload["initial_password"]
|
|
cls.known_passwords.add(cls.initial_password_a)
|
|
|
|
cls.cashier_a = Client("127.0.0.1", cls.port)
|
|
status, _, data = cls.cashier_a.post_json(
|
|
"/api/login",
|
|
{
|
|
"username": "cashier-a",
|
|
"password": cls.initial_password_a,
|
|
"portal": "company",
|
|
},
|
|
)
|
|
assert status == 200, data
|
|
assert as_json(data)["must_change_password"] is True
|
|
status, _, _ = cls.cashier_a.get("/api/batches")
|
|
assert status == 403, "must_change_password must block company API access"
|
|
status, _, data = cls.cashier_a.post_json(
|
|
"/api/password/change",
|
|
{"old_password": cls.initial_password_a, "new_password": CASHIER_A_PASSWORD},
|
|
)
|
|
assert status == 200, data
|
|
|
|
# --- B gets a batch (admin upload), A gets its own batch. ---
|
|
status, _, data = cls.admin.post_multipart(
|
|
"/api/parse",
|
|
{"company_id": str(cls.company_b)},
|
|
CCB_SAMPLE.name,
|
|
CCB_SAMPLE.read_bytes(),
|
|
)
|
|
assert status == 200, data
|
|
cls.b_batch_id = as_json(data)["batch_id"]
|
|
|
|
status, _, data = cls.cashier_a.post_multipart(
|
|
"/api/parse", {}, CITIC_SAMPLE.name, CITIC_SAMPLE.read_bytes()
|
|
)
|
|
assert status == 200, data
|
|
cls.a_batch_id = as_json(data)["batch_id"]
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
cls.httpd.shutdown()
|
|
cls.httpd.server_close()
|
|
server.DB_PATH = cls._old_db_path
|
|
server.STORAGE_DIR = cls._old_storage
|
|
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
|
cls.temp_dir.cleanup()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def fresh_client(self) -> Client:
|
|
return Client("127.0.0.1", self.port)
|
|
|
|
def create_company_user(self, username: str) -> tuple[Client, str, int]:
|
|
status, _, data = self.admin.post_json(
|
|
"/api/admin/users", {"username": username, "company_id": self.company_a}
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
payload = as_json(data)
|
|
initial = payload["initial_password"]
|
|
self.known_passwords.add(initial)
|
|
client = self.fresh_client()
|
|
status, _, data = client.post_json(
|
|
"/api/login",
|
|
{"username": username, "password": initial, "portal": "company"},
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
new_password = "Changed123"
|
|
self.known_passwords.add(new_password)
|
|
status, _, data = client.post_json(
|
|
"/api/password/change",
|
|
{"old_password": initial, "new_password": new_password},
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
return client, new_password, payload["user_id"]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Unauthenticated matrix
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_unauthenticated_api_calls_return_401(self) -> None:
|
|
anon = self.fresh_client()
|
|
for method_check in (
|
|
lambda: anon.post_multipart("/api/parse", {}, "x.xls", b"data"),
|
|
lambda: anon.get("/api/batches"),
|
|
lambda: anon.get(f"/api/batches/{self.a_batch_id}/rows"),
|
|
lambda: anon.get("/api/export.csv"),
|
|
lambda: anon.get("/api/admin/users"),
|
|
lambda: anon.get("/api/admin/companies"),
|
|
lambda: anon.get("/api/admin/audit-log"),
|
|
lambda: anon.get("/api/me"),
|
|
):
|
|
status, _, data = method_check()
|
|
self.assertEqual(401, status, data)
|
|
self.assertEqual("error", as_json(data)["status"])
|
|
|
|
def test_unauthenticated_portal_pages_redirect(self) -> None:
|
|
anon = self.fresh_client()
|
|
for page in ("/admin.html", "/company.html"):
|
|
status, headers, _ = anon.get(page)
|
|
self.assertEqual(302, status, page)
|
|
self.assertEqual("/", headers.get("location"))
|
|
|
|
def test_wrong_password_returns_generic_401(self) -> None:
|
|
anon = self.fresh_client()
|
|
status, _, data = anon.post_json(
|
|
"/api/login",
|
|
{"username": "group-admin", "password": "WrongPass1", "portal": "admin"},
|
|
)
|
|
self.assertEqual(401, status)
|
|
message = as_json(data)["message"]
|
|
self.assertNotIn("密码不正确", message.replace("账号或密码不正确", ""))
|
|
|
|
def test_rate_limit_after_five_failures(self) -> None:
|
|
anon = self.fresh_client()
|
|
for _ in range(5):
|
|
status, _, _ = anon.post_json(
|
|
"/api/login",
|
|
{"username": "ghost-user", "password": "WrongPass1", "portal": "admin"},
|
|
)
|
|
self.assertEqual(401, status)
|
|
status, _, _ = anon.post_json(
|
|
"/api/login",
|
|
{"username": "ghost-user", "password": "WrongPass1", "portal": "admin"},
|
|
)
|
|
self.assertEqual(429, status)
|
|
|
|
def test_portal_mismatch_returns_403(self) -> None:
|
|
anon = self.fresh_client()
|
|
status, _, data = anon.post_json(
|
|
"/api/login",
|
|
{"username": "group-admin", "password": ADMIN_PASSWORD, "portal": "company"},
|
|
)
|
|
self.assertEqual(403, status)
|
|
self.assertIn("端口", as_json(data)["message"])
|
|
|
|
def test_logout_revokes_session(self) -> None:
|
|
client, _, _ = self.create_company_user("cashier-logout")
|
|
status, _, _ = client.get("/api/me")
|
|
self.assertEqual(200, status)
|
|
status, _, _ = client.request("POST", "/api/logout")
|
|
self.assertEqual(200, status)
|
|
self.assertNotIn("cw_session", client.cookies)
|
|
status, _, _ = client.get("/api/me")
|
|
self.assertEqual(401, status)
|
|
|
|
def test_expired_session_returns_401(self) -> None:
|
|
client, _, user_id = self.create_company_user("cashier-expired")
|
|
connection = connect(self.db_path)
|
|
try:
|
|
with connection:
|
|
connection.execute(
|
|
"UPDATE sessions SET expires_at = ? WHERE user_id = ?",
|
|
("2000-01-01T00:00:00+00:00", user_id),
|
|
)
|
|
finally:
|
|
connection.close()
|
|
status, _, _ = client.get("/api/me")
|
|
self.assertEqual(401, status)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Admin and company lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_duplicate_company_name_returns_409(self) -> None:
|
|
status, _, _ = self.admin.post_json("/api/admin/companies", {"name": "甲公司"})
|
|
self.assertEqual(409, status)
|
|
|
|
def test_me_returns_profile_without_password_material(self) -> None:
|
|
status, _, data = self.cashier_a.get("/api/me")
|
|
self.assertEqual(200, status)
|
|
payload = as_json(data)
|
|
self.assertEqual("cashier-a", payload["username"])
|
|
self.assertEqual("company", payload["role"])
|
|
self.assertEqual(self.company_a, payload["company_id"])
|
|
self.assertEqual("甲公司", payload["company_name"])
|
|
self.assertFalse(payload["must_change_password"])
|
|
|
|
def test_company_user_forbidden_on_all_admin_endpoints(self) -> None:
|
|
calls = (
|
|
lambda: self.cashier_a.get("/api/admin/companies"),
|
|
lambda: self.cashier_a.post_json("/api/admin/companies", {"name": "丙公司"}),
|
|
lambda: self.cashier_a.get("/api/admin/users"),
|
|
lambda: self.cashier_a.post_json(
|
|
"/api/admin/users", {"username": "x", "company_id": self.company_a}
|
|
),
|
|
lambda: self.cashier_a.request("POST", "/api/admin/users/1/disable"),
|
|
lambda: self.cashier_a.request("POST", "/api/admin/users/1/enable"),
|
|
lambda: self.cashier_a.request("POST", "/api/admin/users/1/reset-password"),
|
|
lambda: self.cashier_a.get("/api/admin/audit-log"),
|
|
)
|
|
for call in calls:
|
|
status, _, data = call()
|
|
self.assertEqual(403, status, data)
|
|
|
|
def test_admin_upload_requires_company_id(self) -> None:
|
|
status, _, data = self.admin.post_multipart(
|
|
"/api/parse", {}, CCB_SAMPLE.name, CCB_SAMPLE.read_bytes()
|
|
)
|
|
self.assertEqual(400, status, data)
|
|
|
|
def test_admin_batches_filter_by_company(self) -> None:
|
|
status, _, data = self.admin.get(f"/api/batches?company_id={self.company_b}")
|
|
self.assertEqual(200, status)
|
|
batches = as_json(data)["batches"]
|
|
self.assertTrue(batches)
|
|
for batch in batches:
|
|
self.assertEqual(self.company_b, batch["company_id"])
|
|
self.assertEqual("乙公司", batch["company_name"])
|
|
|
|
# ------------------------------------------------------------------
|
|
# Tenant isolation
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_batches_scoped_to_own_company(self) -> None:
|
|
status, _, data = self.cashier_a.get("/api/batches")
|
|
self.assertEqual(200, status)
|
|
batches = as_json(data)["batches"]
|
|
self.assertTrue(batches)
|
|
for batch in batches:
|
|
self.assertEqual(self.company_a, batch["company_id"])
|
|
self.assertNotIn(self.b_batch_id, [batch["id"] for batch in batches])
|
|
|
|
def test_idor_batch_rows_of_other_company_return_404(self) -> None:
|
|
status, _, _ = self.cashier_a.get(f"/api/batches/{self.b_batch_id}/rows")
|
|
self.assertEqual(404, status)
|
|
status, _, data = self.cashier_a.get(f"/api/batches/{self.a_batch_id}/rows")
|
|
self.assertEqual(200, status)
|
|
self.assertTrue(as_json(data)["rows"])
|
|
|
|
def test_export_csv_forced_to_own_company(self) -> None:
|
|
status, _, _ = self.cashier_a.get(f"/api/export.csv?company_id={self.company_b}")
|
|
self.assertEqual(403, status)
|
|
|
|
status, headers, data = self.cashier_a.get("/api/export.csv")
|
|
self.assertEqual(200, status)
|
|
self.assertEqual("text/csv; charset=utf-8", headers.get("content-type"))
|
|
text = data.decode("utf-8-sig")
|
|
lines = [line for line in text.splitlines() if line]
|
|
self.assertGreater(len(lines), 1)
|
|
for line in lines[1:]:
|
|
self.assertEqual(str(self.a_batch_id), line.split(",", 1)[0])
|
|
|
|
def test_cross_company_upload_is_recorded_under_own_company(self) -> None:
|
|
# A uploads B's file bytes while claiming company B in the form; the
|
|
# server must bind the new (duplicate) batch to A from the session.
|
|
status, _, data = self.cashier_a.post_multipart(
|
|
"/api/parse",
|
|
{"company_id": str(self.company_b)},
|
|
CCB_SAMPLE.name,
|
|
CCB_SAMPLE.read_bytes(),
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
payload = as_json(data)
|
|
self.assertEqual("duplicate", payload["status"])
|
|
# Cross-company duplicate: no original batch id, bank, template,
|
|
# period, transaction count or diagnostics may be exposed.
|
|
self.assertNotEqual(self.b_batch_id, payload.get("batch_id"))
|
|
for leaked_key in ("bank", "template", "header_row", "period_start", "period_end", "transactions", "warnings"):
|
|
self.assertNotIn(leaked_key, payload, leaked_key)
|
|
connection = connect(self.db_path)
|
|
try:
|
|
duplicate = connection.execute(
|
|
"SELECT company_id FROM import_batches WHERE status = 'duplicate'"
|
|
).fetchone()
|
|
finally:
|
|
connection.close()
|
|
self.assertIsNotNone(duplicate)
|
|
self.assertEqual(self.company_a, duplicate["company_id"])
|
|
|
|
def test_same_company_duplicate_keeps_idempotent_summary(self) -> None:
|
|
# A re-uploads its own file; the idempotent duplicate response keeps
|
|
# the original batch id and the parsed summary.
|
|
status, _, data = self.cashier_a.post_multipart(
|
|
"/api/parse", {}, CITIC_SAMPLE.name, CITIC_SAMPLE.read_bytes()
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
payload = as_json(data)
|
|
self.assertEqual("duplicate", payload["status"])
|
|
self.assertEqual(self.a_batch_id, payload["batch_id"])
|
|
self.assertEqual("中信银行", payload.get("bank"))
|
|
self.assertTrue(payload.get("transactions", 0) > 0)
|
|
self.assertIn("period_start", payload)
|
|
self.assertIn("warnings", payload)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Disable / reset flows
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_disabled_user_session_and_login_rejected(self) -> None:
|
|
client, password, user_id = self.create_company_user("cashier-disable")
|
|
status, _, _ = client.get("/api/me")
|
|
self.assertEqual(200, status)
|
|
|
|
status, _, data = self.admin.request("POST", f"/api/admin/users/{user_id}/disable")
|
|
self.assertEqual(200, status, data)
|
|
|
|
status, _, _ = client.get("/api/me")
|
|
self.assertEqual(401, status)
|
|
|
|
fresh = self.fresh_client()
|
|
status, _, data = fresh.post_json(
|
|
"/api/login",
|
|
{"username": "cashier-disable", "password": password, "portal": "company"},
|
|
)
|
|
self.assertEqual(403, status, data)
|
|
|
|
def test_reset_password_returns_once_and_revokes_sessions(self) -> None:
|
|
client, old_password, user_id = self.create_company_user("cashier-reset")
|
|
status, _, data = self.admin.request(
|
|
"POST", f"/api/admin/users/{user_id}/reset-password"
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
new_password = as_json(data)["initial_password"]
|
|
self.known_passwords.add(new_password)
|
|
|
|
status, _, _ = client.get("/api/me")
|
|
self.assertEqual(401, status)
|
|
|
|
fresh = self.fresh_client()
|
|
status, _, data = fresh.post_json(
|
|
"/api/login",
|
|
{"username": "cashier-reset", "password": new_password, "portal": "company"},
|
|
)
|
|
self.assertEqual(200, status, data)
|
|
self.assertTrue(as_json(data)["must_change_password"])
|
|
|
|
# ------------------------------------------------------------------
|
|
# Secrets hygiene
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_no_response_contains_password_hash(self) -> None:
|
|
bodies = []
|
|
status, _, data = self.admin.get("/api/admin/users")
|
|
self.assertEqual(200, status)
|
|
bodies.append(data)
|
|
status, _, data = self.admin.get("/api/admin/audit-log?limit=100")
|
|
self.assertEqual(200, status)
|
|
bodies.append(data)
|
|
status, _, data = self.cashier_a.get("/api/me")
|
|
bodies.append(data)
|
|
status, _, data = self.cashier_a.get("/api/batches")
|
|
bodies.append(data)
|
|
for body in bodies:
|
|
self.assertNotIn("password_hash", body.decode("utf-8"))
|
|
|
|
def test_audit_log_contains_no_plaintext_passwords(self) -> None:
|
|
connection = connect(self.db_path)
|
|
try:
|
|
rows = connection.execute(
|
|
"SELECT detail, target FROM audit_log"
|
|
).fetchall()
|
|
finally:
|
|
connection.close()
|
|
for password in self.known_passwords:
|
|
for row in rows:
|
|
self.assertNotIn(password, row["detail"] or "")
|
|
self.assertNotIn(password, row["target"] or "")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|