537 lines
21 KiB
Python
537 lines
21 KiB
Python
"""WP-230A regression tests: real per-endpoint management RBAC.
|
|
|
|
Exercises the actual FastAPI routes over real HTTP (via TestClient), through
|
|
a real PKCE/BFF login against a fake-but-real OIDC provider
|
|
(services/control_api/auth/fake_provider.py), against a real SQLite-backed
|
|
app built with services/runtime/control_api.py's build_app() -- not a
|
|
hand-rolled stand-in for the routing. This is deliberately heavier than a
|
|
pure services/control_api/authz.py unit test (see test_wp230_authz.py for
|
|
that): it is the only way to prove the *actual wiring* -- that each route
|
|
really does call the permission matrix with the action its own design
|
|
intends -- rather than just proving the matrix itself is correct in
|
|
isolation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from http.cookies import SimpleCookie
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from services.control_api.auth.config import OIDCSettings
|
|
from services.control_api.auth.fake_provider import FakeOIDCProvider
|
|
from services.control_api.auth.models import ManagementRole
|
|
from services.control_api.contextforge.fake import FakeContextForge
|
|
from services.control_api.contextforge.models import ContextForgeSettings
|
|
from services.control_api.persistence import Base, create_engine_from_url, session_factory
|
|
from services.runtime.control_api import build_app
|
|
from services.runtime.state_sync import sync_runtime_state
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ISSUER = "https://fake.oidc.test"
|
|
ROLE_GROUPS: dict[ManagementRole, str] = {
|
|
ManagementRole.VIEWER: "hub-viewer",
|
|
ManagementRole.AUDITOR: "hub-auditor",
|
|
ManagementRole.OPERATOR: "hub-operator",
|
|
ManagementRole.ADMIN: "hub-admin",
|
|
}
|
|
ALL_ROLES = tuple(ROLE_GROUPS)
|
|
|
|
|
|
@dataclass
|
|
class RoleSession:
|
|
role: ManagementRole
|
|
client: TestClient
|
|
csrf: str
|
|
|
|
def get(self, path: str, **kwargs: object) -> object:
|
|
return self.client.get(path, **kwargs) # type: ignore[arg-type]
|
|
|
|
def post(self, path: str, **kwargs: object) -> object:
|
|
headers = dict(kwargs.pop("headers", {}) or {}) # type: ignore[arg-type]
|
|
headers["X-CSRF-Token"] = self.csrf
|
|
return self.client.post(path, headers=headers, **kwargs) # type: ignore[arg-type]
|
|
|
|
|
|
def _login(client: TestClient, provider: FakeOIDCProvider) -> str:
|
|
login_response = client.get("/auth/login", follow_redirects=False)
|
|
assert login_response.status_code == 307
|
|
location = login_response.headers["location"]
|
|
query = parse_qs(urlparse(location).query)
|
|
code = provider.authorize(location)
|
|
callback_response = client.get(
|
|
"/auth/callback",
|
|
params={"code": code, "state": query["state"][0]},
|
|
follow_redirects=False,
|
|
)
|
|
assert callback_response.status_code == 303
|
|
# The session cookie is Secure (required by OIDCSettings), and
|
|
# TestClient's default base_url is plain http:// -- its cookie jar
|
|
# correctly refuses to store/replay a Secure cookie over that scheme, so
|
|
# the cookie is set directly rather than relying on the jar (same
|
|
# pattern tests/test_auth.py already uses for the same reason).
|
|
cookie = SimpleCookie()
|
|
cookie.load(callback_response.headers["set-cookie"])
|
|
cookie_name = next(iter(cookie))
|
|
client.cookies.set(cookie_name, cookie[cookie_name].value)
|
|
|
|
session_response = client.get("/auth/session")
|
|
assert session_response.status_code == 200, session_response.text
|
|
return str(session_response.json()["csrf_token"])
|
|
|
|
|
|
def _build_role_app(db_path: Path, role: ManagementRole) -> tuple[object, FakeOIDCProvider]:
|
|
# These module-scoped sessions deliberately cover the complete endpoint
|
|
# matrix. Give this fixture the production session ceiling so a slow CI
|
|
# runner cannot turn suite duration into unrelated RBAC failures.
|
|
provider = FakeOIDCProvider(
|
|
issuer=ISSUER,
|
|
user_groups=(ROLE_GROUPS[role],),
|
|
token_ttl_seconds=28_800,
|
|
)
|
|
oidc = OIDCSettings(
|
|
issuer=ISSUER,
|
|
audience=provider.audience,
|
|
client_id="hub-bff",
|
|
redirect_uri="https://hub.test/auth/callback",
|
|
secure_cookie=True,
|
|
role_group_map={group: r for r, group in ROLE_GROUPS.items()},
|
|
)
|
|
contextforge_fake = FakeContextForge()
|
|
os.environ["WP230_FAKE_CONTEXTFORGE_TOKEN"] = "fake-token" # noqa: S105
|
|
engine = create_engine_from_url(f"sqlite:///{db_path}")
|
|
app = build_app(
|
|
oidc=oidc,
|
|
provider_transport=provider.transport(),
|
|
contextforge_settings=ContextForgeSettings(
|
|
base_url="https://contextforge.test",
|
|
auth_token_env="WP230_FAKE_CONTEXTFORGE_TOKEN", # noqa: S106 -- env var name, not a secret
|
|
),
|
|
contextforge_transport=contextforge_fake.transport(),
|
|
engine=engine,
|
|
catalog_root=ROOT / "catalog",
|
|
)
|
|
return app, provider
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def _client_bindings_env() -> Iterator[None]:
|
|
# sync_runtime_state/reconcile_runtime_state both read this at call time
|
|
# (not just once at seeding) -- catalog.apply re-invokes it per request,
|
|
# so it must stay set for the whole module, not just around seeding.
|
|
previous = os.environ.get("MCP_CLIENT_BINDINGS_JSON")
|
|
os.environ["MCP_CLIENT_BINDINGS_JSON"] = "[]"
|
|
yield
|
|
if previous is None:
|
|
os.environ.pop("MCP_CLIENT_BINDINGS_JSON", None)
|
|
else:
|
|
os.environ["MCP_CLIENT_BINDINGS_JSON"] = previous
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def db_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
path = tmp_path_factory.mktemp("wp230-rbac") / "rbac.db"
|
|
engine = create_engine_from_url(f"sqlite:///{path}")
|
|
Base.metadata.create_all(engine)
|
|
sessions = session_factory(engine)
|
|
with sessions() as session, session.begin():
|
|
sync_runtime_state(session, ROOT / "catalog", ISSUER)
|
|
engine.dispose()
|
|
return path
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def role_sessions(db_path: Path) -> Iterator[dict[ManagementRole, RoleSession]]:
|
|
sessions: dict[ManagementRole, RoleSession] = {}
|
|
clients: list[TestClient] = []
|
|
for role in ALL_ROLES:
|
|
app, provider = _build_role_app(db_path, role)
|
|
client = TestClient(app)
|
|
clients.append(client)
|
|
csrf = _login(client, provider)
|
|
sessions[role] = RoleSession(role=role, client=client, csrf=csrf)
|
|
yield sessions
|
|
for client in clients:
|
|
client.close()
|
|
|
|
|
|
# --- Reads: available to every role -----------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("role", ALL_ROLES)
|
|
def test_dashboard_read_available_to_every_role(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].get("/api/dashboard")
|
|
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["projects", "servers", "tools", "connectors", "policies"])
|
|
@pytest.mark.parametrize("role", ALL_ROLES)
|
|
def test_resource_read_available_to_every_role(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole, kind: str
|
|
) -> None:
|
|
response = role_sessions[role].get(f"/api/{kind}")
|
|
assert response.status_code == 200, (role, kind, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("role", ALL_ROLES)
|
|
def test_client_list_available_to_every_role(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].get("/api/client-management")
|
|
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("role", ALL_ROLES)
|
|
def test_policy_simulate_available_to_every_role(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].post(
|
|
"/api/policies/simulate",
|
|
json={"project": "nonexistent", "environment": "prod", "tool": "nonexistent"},
|
|
)
|
|
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("role", ALL_ROLES)
|
|
def test_catalog_preview_available_to_every_role(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].post("/api/catalog/preview", json={"project": "ludarium"})
|
|
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
|
|
preview = response.json() # type: ignore[union-attr]
|
|
assert preview["valid"] is True
|
|
assert preview["changes"] == ["reconcile all registered project routes, including ludarium"]
|
|
|
|
|
|
# --- Tool readiness: bounded live probe, operator+ only ------------------
|
|
|
|
|
|
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
|
|
def test_tool_readiness_denied_below_operator(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].post(
|
|
"/api/tools/test",
|
|
json={
|
|
"project": "mobilityops",
|
|
"environment": "prod",
|
|
"tool": "mobilityops.operations.summary",
|
|
"arguments": {},
|
|
},
|
|
)
|
|
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("role", [ManagementRole.OPERATOR, ManagementRole.ADMIN])
|
|
def test_tool_readiness_is_wired_for_operator_and_admin(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].post(
|
|
"/api/tools/test",
|
|
json={
|
|
"project": "mobilityops",
|
|
"environment": "prod",
|
|
"tool": "mobilityops.operations.summary",
|
|
"arguments": {},
|
|
},
|
|
)
|
|
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
|
|
body = response.json() # type: ignore[union-attr]
|
|
assert body["note"] == "Readiness only; no MCP tool invocation was performed."
|
|
assert body["checks"]["input"]["valid"] is True
|
|
|
|
|
|
def test_tool_readiness_proves_full_registered_path_after_reconciliation(
|
|
role_sessions: dict[ManagementRole, RoleSession],
|
|
) -> None:
|
|
admin = role_sessions[ManagementRole.ADMIN]
|
|
applied = admin.post("/api/catalog/apply", json={"project": "mobilityops"})
|
|
assert applied.status_code == 200, applied.text # type: ignore[union-attr]
|
|
|
|
response = admin.post(
|
|
"/api/tools/test",
|
|
json={
|
|
"project": "mobilityops",
|
|
"environment": "prod",
|
|
"tool": "mobilityops.operations.summary",
|
|
"arguments": {},
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text # type: ignore[union-attr]
|
|
body = response.json() # type: ignore[union-attr]
|
|
assert body["ready"] is True
|
|
assert body["checks"]["binding"]["valid"] is True
|
|
assert body["checks"]["policy"]["allowed"] is True
|
|
assert body["checks"]["connector"]["reachable"] is True
|
|
drift = admin.get("/api/dashboard").json()["drift"] # type: ignore[union-attr]
|
|
assert drift == {
|
|
"count": 0,
|
|
"projects": [],
|
|
"known": True,
|
|
"detail": "No connector gateway drift measured.",
|
|
}
|
|
|
|
|
|
def test_tool_readiness_rejects_an_unverified_catalog_binding(
|
|
role_sessions: dict[ManagementRole, RoleSession],
|
|
) -> None:
|
|
response = role_sessions[ManagementRole.OPERATOR].post(
|
|
"/api/tools/test",
|
|
json={
|
|
"project": "devrunbook",
|
|
"environment": "prod",
|
|
"tool": "knowledge.index.status",
|
|
"arguments": {},
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text # type: ignore[union-attr]
|
|
body = response.json() # type: ignore[union-attr]
|
|
assert body["ready"] is False
|
|
assert body["checks"]["binding"]["valid"] is False
|
|
|
|
|
|
def test_tool_readiness_validates_input_without_echoing_values(
|
|
role_sessions: dict[ManagementRole, RoleSession],
|
|
) -> None:
|
|
response = role_sessions[ManagementRole.OPERATOR].post(
|
|
"/api/tools/test",
|
|
json={
|
|
"project": "mobilityops",
|
|
"environment": "prod",
|
|
"tool": "mobilityops.vehicle.get",
|
|
"arguments": {"unexpected": "must-not-be-echoed"},
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text # type: ignore[union-attr]
|
|
serialized = response.text # type: ignore[union-attr]
|
|
body = response.json() # type: ignore[union-attr]
|
|
assert body["ready"] is False
|
|
assert body["checks"]["input"]["valid"] is False
|
|
assert "must-not-be-echoed" not in serialized
|
|
assert len(body["checks"]["input"]["errors"]) == 2
|
|
|
|
|
|
def test_tool_readiness_reports_cataloged_but_undeployed_connector_type(
|
|
role_sessions: dict[ManagementRole, RoleSession],
|
|
) -> None:
|
|
response = role_sessions[ManagementRole.OPERATOR].post(
|
|
"/api/tools/test",
|
|
json={
|
|
"project": "ludarium",
|
|
"environment": "prod",
|
|
"tool": "deployment.project.status",
|
|
"arguments": {},
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text # type: ignore[union-attr]
|
|
body = response.json() # type: ignore[union-attr]
|
|
assert body["ready"] is False
|
|
assert body["checks"]["connector"] == {
|
|
"reachable": False,
|
|
"status": "not-deployed",
|
|
"detail": "No production connector gateway is registered for this connector type.",
|
|
}
|
|
|
|
|
|
def test_tool_readiness_enforces_argument_byte_limit(
|
|
role_sessions: dict[ManagementRole, RoleSession],
|
|
) -> None:
|
|
response = role_sessions[ManagementRole.OPERATOR].post(
|
|
"/api/tools/test",
|
|
json={
|
|
"project": "mobilityops",
|
|
"environment": "prod",
|
|
"tool": "mobilityops.knowledge.search",
|
|
"arguments": {"query": "🚗" * 9000},
|
|
},
|
|
)
|
|
assert response.status_code == 422, response.text # type: ignore[union-attr]
|
|
|
|
|
|
def test_tool_readiness_request_contract_fails_closed_on_extra_fields(
|
|
role_sessions: dict[ManagementRole, RoleSession],
|
|
) -> None:
|
|
response = role_sessions[ManagementRole.ADMIN].post(
|
|
"/api/tools/test",
|
|
json={
|
|
"project": "mobilityops",
|
|
"environment": "prod",
|
|
"tool": "mobilityops.operations.summary",
|
|
"arguments": {},
|
|
"invoke": True,
|
|
},
|
|
)
|
|
assert response.status_code == 422, response.text # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
|
|
def test_tool_invocation_denied_below_operator_before_secret_or_network_access(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].post(
|
|
"/api/tools/invoke",
|
|
json={
|
|
"project": "mobilityops",
|
|
"environment": "prod",
|
|
"tool": "mobilityops.operations.summary",
|
|
"arguments": {},
|
|
},
|
|
)
|
|
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
|
|
def test_live_operations_check_denied_below_operator(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].post("/api/operations/check")
|
|
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("role", [ManagementRole.OPERATOR, ManagementRole.ADMIN])
|
|
def test_live_operations_check_available_to_operator_and_admin(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].post("/api/operations/check")
|
|
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
|
|
assert response.json()["connectors"] # type: ignore[union-attr]
|
|
assert {"id": "knowledge-default", "name": "Knowledge Default"} in response.json()[
|
|
"connectors"
|
|
]["itworx-knowledge"]["connectorRefs"] # type: ignore[union-attr]
|
|
|
|
|
|
# --- Audit read: auditor+ only, viewer denied ---------------------------
|
|
|
|
|
|
def test_audit_read_denied_for_viewer(role_sessions: dict[ManagementRole, RoleSession]) -> None:
|
|
response = role_sessions[ManagementRole.VIEWER].get("/api/audit")
|
|
assert response.status_code == 403, response.text # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"role", [ManagementRole.AUDITOR, ManagementRole.OPERATOR, ManagementRole.ADMIN]
|
|
)
|
|
def test_audit_read_allowed_for_auditor_and_above(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].get("/api/audit")
|
|
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
# --- Client lifecycle mutations: exact role boundaries ------------------
|
|
|
|
|
|
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
|
|
def test_client_create_denied_below_admin(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].post(
|
|
"/api/client-management",
|
|
json={"name": "x", "project": "ludarium", "environment": "prod", "kind": "native-2026"},
|
|
)
|
|
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
def test_client_create_denied_for_operator(
|
|
role_sessions: dict[ManagementRole, RoleSession],
|
|
) -> None:
|
|
response = role_sessions[ManagementRole.OPERATOR].post(
|
|
"/api/client-management",
|
|
json={"name": "x", "project": "ludarium", "environment": "prod", "kind": "native-2026"},
|
|
)
|
|
assert response.status_code == 403, response.text # type: ignore[union-attr]
|
|
|
|
|
|
def test_client_create_allowed_for_admin(role_sessions: dict[ManagementRole, RoleSession]) -> None:
|
|
response = role_sessions[ManagementRole.ADMIN].post(
|
|
"/api/client-management",
|
|
json={
|
|
"name": "wp230-admin-created",
|
|
"project": "ludarium",
|
|
"environment": "prod",
|
|
"kind": "native-2026",
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("action", ["bootstrap", "verify", "disable", "revoke"])
|
|
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
|
|
def test_client_lifecycle_actions_denied_below_operator(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole, action: str
|
|
) -> None:
|
|
# A nonexistent client ID is fine here -- the RBAC check happens before
|
|
# the client lookup, so a 403 (not a 404) proves the gate fired first.
|
|
response = role_sessions[role].post(
|
|
f"/api/client-management/00000000-0000-0000-0000-000000000000/{action}"
|
|
)
|
|
assert response.status_code == 403, (role, action, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
@pytest.mark.parametrize("action", ["bootstrap", "verify", "disable"])
|
|
def test_client_lifecycle_actions_allowed_for_operator(
|
|
role_sessions: dict[ManagementRole, RoleSession], action: str
|
|
) -> None:
|
|
# Create the client via the admin session (create is admin-only), then
|
|
# prove operator can perform the bounded lifecycle action on it.
|
|
created = role_sessions[ManagementRole.ADMIN].post(
|
|
"/api/client-management",
|
|
json={
|
|
"name": f"wp230-operator-{action}",
|
|
"project": "ludarium",
|
|
"environment": "prod",
|
|
"kind": "native-2026",
|
|
},
|
|
)
|
|
assert created.status_code == 201, created.text # type: ignore[union-attr]
|
|
client_id = created.json()["client"]["id"] # type: ignore[union-attr]
|
|
|
|
response = role_sessions[ManagementRole.OPERATOR].post(
|
|
f"/api/client-management/{client_id}/{action}"
|
|
)
|
|
assert response.status_code == 200, (action, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
# --- Catalog apply: admin-only -------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"role", [ManagementRole.VIEWER, ManagementRole.AUDITOR, ManagementRole.OPERATOR]
|
|
)
|
|
def test_catalog_apply_denied_below_admin(
|
|
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
|
) -> None:
|
|
response = role_sessions[role].post("/api/catalog/apply", json={"project": "ludarium"})
|
|
assert response.status_code == 403, (role, response.text) # type: ignore[union-attr]
|
|
|
|
|
|
def test_catalog_apply_not_blocked_by_rbac_for_admin(
|
|
role_sessions: dict[ManagementRole, RoleSession],
|
|
) -> None:
|
|
# Whether reconciliation itself succeeds depends on the fake
|
|
# ContextForge/catalog fixture state, which is out of scope for an RBAC
|
|
# test -- what matters here is that admin is never turned away at 403.
|
|
response = role_sessions[ManagementRole.ADMIN].post(
|
|
"/api/catalog/apply", json={"project": "ludarium"}
|
|
)
|
|
assert response.status_code != 403, response.text # type: ignore[union-attr]
|
|
|
|
|
|
# --- Unauthenticated access ------------------------------------------------
|
|
|
|
|
|
def test_unauthenticated_request_is_401_not_403(db_path: Path) -> None:
|
|
app, _ = _build_role_app(db_path, ManagementRole.VIEWER)
|
|
anonymous = TestClient(app)
|
|
response = anonymous.get("/api/dashboard")
|
|
assert response.status_code == 401, response.text
|
|
anonymous.close()
|