diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py new file mode 100644 index 0000000..86895c5 --- /dev/null +++ b/tests/test_api_admin.py @@ -0,0 +1,37 @@ +from fastapi.testclient import TestClient + +from web.app import app +from web.session import _COOKIE_NAME, create_test_session + +client = TestClient(app, raise_server_exceptions=True) + + +def test_admin_status_requires_admin(): + cookie = create_test_session({"docusign_user_email": "user@example.com"}) + resp = client.get("/api/admin/status", cookies={_COOKIE_NAME: cookie}) + assert resp.status_code == 403 + + +def test_admin_status_returns_build_details(monkeypatch): + import web.config as cfg + + monkeypatch.setattr(cfg.settings, "admin_emails_raw", "admin@example.com") + monkeypatch.setattr(cfg.settings, "build_id", "testbuild") + monkeypatch.setattr(cfg.settings, "asset_version", "testbuild") + + cookie = create_test_session({"docusign_user_email": "admin@example.com"}) + resp = client.get("/api/admin/status", cookies={_COOKIE_NAME: cookie}) + assert resp.status_code == 200 + data = resp.json() + assert data["build_id"] == "testbuild" + assert data["asset_version"] == "testbuild" + + +def test_index_includes_asset_version(monkeypatch): + import web.config as cfg + + monkeypatch.setattr(cfg.settings, "asset_version", "asset123") + resp = client.get("/") + assert resp.status_code == 200 + assert "/static/js/app.js?v=asset123" in resp.text + assert "/static/css/base.css?v=asset123" in resp.text diff --git a/web/app.py b/web/app.py index ebc66ec..4e3f42c 100644 --- a/web/app.py +++ b/web/app.py @@ -11,11 +11,11 @@ From the project root. from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, HTMLResponse import os from web.config import settings -from web.routers import auth, templates, migrate, verify, audit +from web.routers import auth, templates, migrate, verify, audit, admin app = FastAPI( title="Adobe Sign → DocuSign Migrator", @@ -29,6 +29,7 @@ app.include_router(templates.router, prefix="/api/templates", tags=["templates"] app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"]) app.include_router(verify.router, prefix="/api/verify", tags=["verify"]) app.include_router(audit.router, prefix="/api/audit", tags=["audit"]) +app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) # Static files (frontend) _static_dir = os.path.join(os.path.dirname(__file__), "static") @@ -45,5 +46,8 @@ def health(): def index(): index_path = os.path.join(_static_dir, "index.html") if os.path.exists(index_path): - return FileResponse(index_path) + with open(index_path, encoding="utf-8") as f: + html = f.read() + html = html.replace("{{ASSET_VERSION}}", settings.asset_version) + return HTMLResponse(html) return {"message": "Adobe Sign → DocuSign Migrator API", "docs": "/api/docs"} diff --git a/web/config.py b/web/config.py index 78a677b..ae3eb57 100644 --- a/web/config.py +++ b/web/config.py @@ -6,11 +6,34 @@ All values come from .env or environment variables. """ import os +import subprocess from dotenv import load_dotenv load_dotenv() +def _project_root() -> str: + return os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + +def _detect_build_id(default: str) -> str: + env_build = os.getenv("APP_BUILD_ID", "").strip() + if env_build: + return env_build + try: + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=_project_root(), + check=True, + capture_output=True, + text=True, + ) + build_id = result.stdout.strip() + return build_id or default + except Exception: + return default + + class Settings: # Adobe Sign OAuth adobe_client_id: str = os.getenv("ADOBE_CLIENT_ID", "") @@ -40,6 +63,8 @@ class Settings: # App version: str = "2.0" + build_id: str = _detect_build_id("dev") + asset_version: str = os.getenv("ASSET_VERSION", build_id) @property def admin_emails(self) -> set[str]: diff --git a/web/routers/admin.py b/web/routers/admin.py new file mode 100644 index 0000000..c695eb5 --- /dev/null +++ b/web/routers/admin.py @@ -0,0 +1,35 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from web.audit import is_admin_session +from web.config import settings +from web.session import get_session, session_public_view + +router = APIRouter() + + +@router.get("/status") +def admin_status(request: Request): + session = get_session(request) + if not is_admin_session(session): + return JSONResponse({"error": "admin access required"}, status_code=403) + + public_session = session_public_view(session) + return { + "version": settings.version, + "build_id": settings.build_id, + "asset_version": settings.asset_version, + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "session": public_session, + "environment": { + "docusign_base_url": settings.docusign_base_url, + "docusign_auth_server": settings.docusign_auth_server, + "docusign_redirect_uri": settings.docusign_redirect_uri, + "adobe_sign_base_url": settings.adobe_sign_base_url, + "adobe_redirect_uri": settings.adobe_redirect_uri, + "session_store_dir": settings.session_store_dir, + "audit_log_file": settings.audit_log_file, + }, + } diff --git a/web/static/css/base.css b/web/static/css/base.css index 0547ee8..28f6c96 100644 --- a/web/static/css/base.css +++ b/web/static/css/base.css @@ -170,6 +170,10 @@ tr:hover td { background: #FAFBFC; } .cb { width: 15px; height: 15px; accent-color: var(--cobalt); cursor: pointer; flex-shrink: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .activity-details { min-width: 320px; font-size: var(--font-size-sm); color: var(--text-muted); } +.admin-status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px 20px; } +.admin-status-row { display: flex; flex-direction: column; gap: 4px; } +.admin-status-label { font-size: var(--font-size-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); } +.admin-status-value { font-size: var(--font-size-base); color: var(--text); word-break: break-word; } /* ── Empty state ── */ .empty-state { diff --git a/web/static/index.html b/web/static/index.html index 0148c5d..30c4def 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -4,13 +4,13 @@