- Add Workspace domain (entity, repository, service, handler, DTO) - Add multi-tenant K8s client with tenant binding and quota management - Add K8s diagnostics client (instance diagnostics) - Add authorization middleware (authz package) - Restructure frontend to feature-based architecture (features/) - Add User Management page in configuration - Add AccessDenied page and route guards - Refactor shared components (form inputs, layout, UI) - Update Tailwind config for new design system - Add comprehensive documentation (docs/, tasks/, plans) - Improve cluster service with better kubeconfig handling - Add tests for crypto, config, helm client, tenant binding
118 lines
4.5 KiB
Python
118 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
# Covers frontend role UI behavior for the multi-tenant/RBAC plan: admin/user
|
|
# login, admin-only navigation affordances, user inability to access admin
|
|
# resource management routes, and absence of global-shared controls for users.
|
|
|
|
import os
|
|
import sys
|
|
|
|
|
|
try:
|
|
from playwright.sync_api import expect, sync_playwright
|
|
except ImportError:
|
|
print("SKIP: Playwright is not installed; run after installing frontend test dependencies")
|
|
sys.exit(77)
|
|
|
|
|
|
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:18080")
|
|
ADMIN_USER = os.environ.get("ADMIN_USER", os.environ.get("BOOTSTRAP_ADMIN_USER", "admin"))
|
|
ADMIN_PASS = os.environ.get("ADMIN_PASS", os.environ.get("BOOTSTRAP_ADMIN_PASS", ""))
|
|
USER_A = os.environ.get("USER_A", "")
|
|
USER_A_PASS = os.environ.get("USER_A_PASS", "")
|
|
|
|
|
|
def require_env(name: str, value: str) -> None:
|
|
if not value:
|
|
print(f"SKIP: {name} is required for role UI contract checks")
|
|
sys.exit(77)
|
|
|
|
|
|
def login(page, username: str, password: str) -> None:
|
|
page.goto(FRONTEND_URL, wait_until="networkidle")
|
|
assert "Register" not in page.locator("body").inner_text(timeout=10000), "login page must not expose public registration"
|
|
if page.locator("input[type='password']").count() == 0:
|
|
page.evaluate("localStorage.clear()")
|
|
page.goto(FRONTEND_URL, wait_until="networkidle")
|
|
text_inputs = page.locator("input:not([type='password'])")
|
|
expect(text_inputs.first).to_be_visible(timeout=10000)
|
|
text_inputs.first.fill(username)
|
|
page.locator("input[type='password']").first.fill(password)
|
|
page.get_by_role("button").filter(has_text="Login").last.click()
|
|
page.wait_for_url("**/home", timeout=15000)
|
|
page.wait_for_load_state("networkidle")
|
|
expect(page.locator("body")).not_to_contain_text("Login failed")
|
|
|
|
|
|
def visible_text(page) -> str:
|
|
return page.locator("body").inner_text(timeout=10000)
|
|
|
|
|
|
def assert_user_restrictions(page) -> None:
|
|
body = visible_text(page).lower()
|
|
forbidden_labels = [
|
|
"global shared",
|
|
"global_shared",
|
|
"make shared",
|
|
"all workspaces",
|
|
"admin console",
|
|
"user management",
|
|
"workspace management",
|
|
]
|
|
found = [label for label in forbidden_labels if label in body]
|
|
assert not found, f"user UI exposes admin/global controls: {found}"
|
|
|
|
admin_paths = [
|
|
"/admin",
|
|
"/admin/users",
|
|
"/admin/workspaces",
|
|
"/configuration/users",
|
|
"/configuration/workspaces",
|
|
]
|
|
for path in admin_paths:
|
|
page.goto(FRONTEND_URL.rstrip("/") + path, wait_until="networkidle")
|
|
page.wait_for_timeout(500)
|
|
text = visible_text(page).lower()
|
|
assert "forbidden" in text or "unauthorized" in text or page.url.rstrip("/") != FRONTEND_URL.rstrip("/") + path, (
|
|
f"user can access admin route {path}"
|
|
)
|
|
|
|
|
|
def assert_admin_affordances(page) -> None:
|
|
page.goto(FRONTEND_URL.rstrip("/") + "/configuration/clusters", wait_until="networkidle")
|
|
page.wait_for_load_state("networkidle")
|
|
text = visible_text(page).lower()
|
|
expected_any = ["cluster", "registry", "workspace", "user", "admin"]
|
|
assert any(item in text for item in expected_any), "admin UI did not render management affordances"
|
|
page.goto(FRONTEND_URL.rstrip("/") + "/configuration/users", wait_until="networkidle")
|
|
page.wait_for_load_state("networkidle")
|
|
text = visible_text(page).lower()
|
|
assert "create user" in text and "accounts" in text, "admin user management UI did not render"
|
|
if USER_A:
|
|
row = page.locator("tr").filter(has_text=USER_A).first
|
|
expect(row).to_be_visible(timeout=10000)
|
|
delete_button = row.get_by_role("button", name="Delete")
|
|
expect(delete_button).to_be_visible(timeout=5000)
|
|
box = delete_button.bounding_box()
|
|
viewport = page.viewport_size or {"width": 1440, "height": 950}
|
|
assert box and 0 <= box["x"] <= viewport["width"] - box["width"], "Delete User button is outside the visible viewport"
|
|
|
|
|
|
require_env("ADMIN_PASS or BOOTSTRAP_ADMIN_PASS", ADMIN_PASS)
|
|
require_env("USER_A", USER_A)
|
|
require_env("USER_A_PASS", USER_A_PASS)
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
|
|
admin_page = browser.new_page(viewport={"width": 1440, "height": 950})
|
|
login(admin_page, ADMIN_USER, ADMIN_PASS)
|
|
assert_admin_affordances(admin_page)
|
|
|
|
user_page = browser.new_page(viewport={"width": 1440, "height": 950})
|
|
login(user_page, USER_A, USER_A_PASS)
|
|
assert_user_restrictions(user_page)
|
|
|
|
browser.close()
|
|
|
|
print("PASS: multi-tenant/RBAC UI contract")
|