# 14 | Full-stack verification

## Goal

Exercise customer, staff and CMS workflows in a real browser.

## What you are building

API tests cannot prove form listeners, history navigation or mobile layout. The browser script starts a disposable server, seeds a temporary staff account and tests desktop and 390-pixel workflows. It checks registration, login, projects, tasks, progress, help, navigation, refresh and logout. Run it in development and production mode to test MinifyJS output. Use labels, status messages and alerts; also rehearse with the keyboard.

## Build this chapter

Stop the server. Work in project_manager. Follow each CREATE, whole-file replacement, or marked-block edit below in order.

## Step 1: CREATE - make parent folders, then create this file - tests/test_backoffice.py

This file owns the feature named by its module or component. Read the route/form flow before continuing.

Create the parent directories, then save this complete file.

```python
import asyncio
from flaxon import Flaxon
from flaxon.admin.cms import CMS, CMSField, ContentType
from flaxon.testing import TestClient


def staff(application, permissions):
    admin = application.backoffice
    admin.auth.add_user(
        {
            "username": "editor",
            "password": "EditorLearning123!",
            "roles": [],
            "permissions": permissions,
        }
    )
    token = asyncio.run(admin.auth.login("editor", "EditorLearning123!"))
    return {"cookie": f"session_id={token}", "x-csrf-token": admin.csrf_token()}


def test_public_content_hides_drafts_and_private_fields(application, client):
    articles = application.cms.content_types["help_article"]
    articles.create({"title": "Hidden draft", "status": "draft"})
    item = articles.create(
        {
            "title": "Published guide",
            "body": "<p>Safe</p><script>bad()</script>",
            "status": "published",
        }
    )
    response = client.get("/api/content/articles")
    assert [row["title"] for row in response.json()["data"]] == ["Published guide"]
    detail = client.get("/api/content/articles/" + item["slug"]).json()["data"]
    assert "<script>" not in detail["body"]
    assert "created_at" not in detail
    assert client.get("/api/content/articles/hidden-draft").status_code == 404


def test_application_accounts_do_not_have_staff_access(client, account):
    assert account.call("/admin/cms/api/help_article/items").status_code == 401


def test_editor_can_write_drafts_but_cannot_publish(application, client):
    headers = staff(
        application,
        [
            "help_article.view_help_article",
            "help_article.add_help_article",
            "help_article.change_help_article",
        ],
    )
    path = "/admin/cms/api/help_article/items"
    assert (
        client.post(path, json_data={"title": "Draft"}, headers=headers).status_code
        == 201
    )
    assert (
        client.post(
            path,
            json_data={"title": "Forbidden", "status": "published"},
            headers=headers,
        ).status_code
        == 403
    )
    assert (
        client.post(
            "/admin/cms/api/import/help_article",
            json_data=[{"title": "Forbidden", "status": "published"}],
            headers=headers,
        ).status_code
        == 403
    )


def test_publisher_requires_csrf(application, client):
    headers = staff(
        application, ["help_article.add_help_article", "cms.publish_content"]
    )
    path = "/admin/cms/api/help_article/items"
    assert (
        client.post(
            path,
            json_data={"title": "Published", "status": "published"},
            headers=headers,
        ).status_code
        == 201
    )
    assert (
        client.post(
            path,
            json_data={"title": "Missing token"},
            headers={"cookie": headers["cookie"]},
        ).status_code
        == 400
    )


def test_standalone_cms_is_closed_by_default():
    app = Flaxon("cms-test", debug=True)
    cms = CMS(app)
    cms.register(ContentType("article", fields=[CMSField("title")]))
    assert (
        TestClient(app)
        .post("/admin/cms/api/article/items", json_data={"title": "Forbidden"})
        .status_code
        == 403
    )


def test_database_and_cms_survive_new_application_instance(
    application, client, account, tmp_path
):
    from app import create_app

    account.call("/api/projects/", "post", {"name": "Persistent"})
    articles = application.cms.content_types["help_article"]
    articles.create({"title": "Persistent article", "status": "published"})
    application.cms._save(articles)
    new_app = create_app(
        tmp_path / "app.sqlite3", tmp_path / "admin.sqlite3", debug=True
    )

    async def read_project():
        from models import Project

        async with new_app.db:
            return (await Project.all().first()).name

    assert asyncio.run(read_project()) == "Persistent"
    assert len(new_app.cms.content_types["help_article"].items) == 1


def test_staff_model_adapter_reads_domain_records_and_denies_ungranted_changes(
    application, client, account
):
    account.call("/api/projects/", "post", {"name": "Domain record"})
    headers = staff(application, ["admin.view_dashboard", "project.view_project"])
    response = client.get("/admin/project", headers=headers)
    assert response.status_code == 200
    assert "Domain record" in response.text
    assert client.get("/admin/project/1/edit", headers=headers).status_code == 403
    assert client.post("/admin/project/1/delete", headers=headers).status_code == 403
```

**Say:** "This file owns the feature named by its module or component. Read the route/form flow before continuing."

**Type:** Follow the action above in `tests/test_backoffice.py`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Step 2: CREATE - make parent folders, then create this file - tests/test_cms_security.py

This file owns the feature named by its module or component. Read the route/form flow before continuing.

Create the parent directories, then save this complete file.

```python
import asyncio

import pytest
from flaxon import Flaxon
from flaxon.admin import AdminDashboard
from flaxon.admin.cms import CMS, CMSField, ContentType
from flaxon.testing import TestClient


def setup_editor():
    app = Flaxon("cms-security", debug=True)
    admin = AdminDashboard(
        app,
        strict_permissions=True,
        users=[
            {
                "username": "editor",
                "password": "Editor123!",
                "roles": [],
                "permissions": [
                    "article.view_article",
                    "article.add_article",
                    "article.change_article",
                    "cms.restore_revision",
                ],
            }
        ],
    )
    cms = CMS(app, auth=admin.auth)
    content = cms.register(
        ContentType("article", fields=[CMSField("title", required=True)])
    )
    token = asyncio.run(admin.auth.login("editor", "Editor123!"))
    headers = {"cookie": f"session_id={token}", "x-csrf-token": admin.csrf_token()}
    return TestClient(app), cms, content, headers


def test_standalone_cms_denies_anonymous_requests_by_default():
    app = Flaxon("closed-cms")
    cms = CMS(app)
    cms.register(ContentType("article", fields=[CMSField("title")]))
    client = TestClient(app)
    assert client.get("/admin/cms/api/article/items").status_code == 403
    assert (
        client.post(
            "/admin/cms/api/article/items", json_data={"title": "Anonymous"}
        ).status_code
        == 403
    )


@pytest.mark.parametrize("status", ["approved", "scheduled", "published"])
@pytest.mark.parametrize("method", ["create", "update", "import"])
def test_editor_cannot_publish_through_generic_write_paths(status, method):
    client, cms, content, headers = setup_editor()
    data = {"title": "Blocked", "status": status}
    if method == "create":
        response = client.post(
            "/admin/cms/api/article/items", json_data=data, headers=headers
        )
    elif method == "update":
        item = content.create({"title": "Draft"})
        response = client.put(
            f"/admin/cms/api/article/items/{item['id']}",
            json_data=data,
            headers=headers,
        )
        assert item["status"] == "draft"
    else:
        response = client.post(
            "/admin/cms/api/import/article", json_data=[data], headers=headers
        )
    assert response.status_code == 403
    assert not any(item["status"] == status for item in content.items.values())


def test_editor_cannot_restore_published_revision_or_edit_published_content():
    client, cms, content, headers = setup_editor()
    item = content.create({"title": "Published", "status": "published"})
    assert (
        client.put(
            f"/admin/cms/api/article/items/{item['id']}",
            json_data={"title": "Changed"},
            headers=headers,
        ).status_code
        == 403
    )
    content.update(item["id"], {"status": "draft"})
    assert (
        client.post(
            f"/admin/cms/api/article/items/{item['id']}/restore/0", headers=headers
        ).status_code
        == 403
    )
    assert item["status"] == "draft"


def test_draft_editing_and_publisher_creation_work_with_csrf():
    client, cms, content, headers = setup_editor()
    assert (
        client.post(
            "/admin/cms/api/article/items",
            json_data={"title": "Draft"},
            headers=headers,
        ).status_code
        == 201
    )
    cms.auth.users["editor"]["permissions"].append("cms.publish_content")
    assert (
        client.post(
            "/admin/cms/api/article/items",
            json_data={"title": "Published", "status": "published"},
            headers=headers,
        ).status_code
        == 201
    )
    assert (
        client.post(
            "/admin/cms/api/article/items",
            json_data={"title": "No CSRF"},
            headers={"cookie": headers["cookie"]},
        ).status_code
        == 400
    )


def test_import_authorization_is_checked_before_any_row_is_created():
    client, cms, content, headers = setup_editor()
    response = client.post(
        "/admin/cms/api/import/article",
        json_data=[
            {"title": "Draft"},
            {"title": "Forbidden", "status": "published"},
        ],
        headers=headers,
    )
    assert response.status_code == 403
    assert content.items == {}


@pytest.mark.parametrize("action", ["publish", "unpublish", "custom"])
def test_actions_require_publisher_permission(action):
    client, cms, content, headers = setup_editor()
    item = content.create({"title": "Draft"})
    content.register_action("custom", "Custom", lambda content_type, ids: None)
    response = client.post(
        f"/admin/cms/api/article/actions/{action}",
        json_data={"ids": [item["id"]]},
        headers=headers,
    )
    assert response.status_code == 403
```

**Say:** "This file owns the feature named by its module or component. Read the route/form flow before continuing."

**Type:** Follow the action above in `tests/test_cms_security.py`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Step 3: CREATE - make parent folders, then create this file - scripts/browser_smoke.py

This file owns the feature named by its module or component. Read the route/form flow before continuing.

Create the parent directories, then save this complete file.

```python
"""Run desktop/mobile workflows against a fresh temporary database. No real user data."""

import os
import subprocess
import sys
import secrets
import tempfile
import time
from pathlib import Path
import httpx
from playwright.sync_api import sync_playwright

ROOT = Path(__file__).resolve().parents[1]
BASE = "http://127.0.0.1:8123"


def main():
    with tempfile.TemporaryDirectory() as directory:
        production = "--production" in sys.argv
        env = {
            **os.environ,
            "DATA_DIR": directory,
            "FLAXON_ALLOWED_HOSTS": "127.0.0.1",
            "FLAXON_DEBUG": "0" if production else "1",
            "PUBLIC_ORIGIN": BASE,
            "FLAXON_SECRET_KEY": secrets.token_urlsafe(48),
            "COURSE_SMOKE_STAFF_PASSWORD": "SmokeAa1!" + secrets.token_urlsafe(24),
        }
        subprocess.run(
            [sys.executable, "management.py", "migrate"], cwd=ROOT, env=env, check=True
        )
        # Bootstrap a staff account in the disposable Admin store before startup.
        bootstrap = """
import os
from flaxon.admin.services import AdminAuth, AdminStore
from settings import ADMIN_DATABASE_PATH
store = AdminStore(str(ADMIN_DATABASE_PATH))
auth = AdminAuth(users=[], store=store, strict_permissions=True)
record = auth.add_user({'username': 'course-admin', 'password': os.environ['COURSE_SMOKE_STAFF_PASSWORD'], 'roles': ['administrator']})
store.set('users', 'course-admin', record)
"""
        subprocess.run([sys.executable, "-c", bootstrap], cwd=ROOT, env=env, check=True)
        log = open(Path(directory) / "server.log", "w")
        server = subprocess.Popen(
            [sys.executable, "-m", "uvicorn", "app:app", "--port", "8123"],
            cwd=ROOT,
            env=env,
            stdout=log,
            stderr=log,
        )
        try:
            for _ in range(150):
                try:
                    if httpx.get(BASE + "/", trust_env=False).status_code == 200:
                        break
                except httpx.ConnectError:
                    time.sleep(0.1)
            else:
                raise RuntimeError("Server failed to start")
            with sync_playwright() as playwright:
                browser = playwright.chromium.launch(headless=True)
                staff_context = browser.new_context()
                staff_page = staff_context.new_page()
                staff_page.goto(BASE + "/admin/login")
                staff_page.get_by_label("Username", exact=True).fill("course-admin")
                staff_page.get_by_label("Password", exact=True).fill(
                    env["COURSE_SMOKE_STAFF_PASSWORD"]
                )
                staff_page.get_by_role("button", name="Sign in", exact=True).click()
                staff_page.wait_for_url("**/admin/")
                assert (
                    staff_page.evaluate(
                        "async () => (await fetch('/admin/project')).status"
                    )
                    == 200
                )
                staff_page.goto(BASE + "/admin/cms/")
                csrf = staff_page.locator('meta[name="csrf-token"]').get_attribute(
                    "content"
                )
                response = staff_page.evaluate(
                    """async (csrf) => {
                    const response = await fetch('/admin/cms/api/help_article/items', {
                        method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
                        body: JSON.stringify({title: 'Browser help guide', summary: 'Published by staff', body: '<p>Safe help content.</p>', status: 'published'})
                    });
                    return {status: response.status, body: await response.text()};
                }""",
                    csrf,
                )
                assert response["status"] == 201, response["body"]
                staff_context.close()
                print("Staff Admin login and CMS publication passed")
                for width in [1440, 390]:
                    context = browser.new_context(
                        viewport={"width": width, "height": 900}
                    )
                    page = context.new_page()
                    errors = []
                    page.on("pageerror", lambda error: errors.append(str(error)))
                    page.goto(BASE + "/login")
                    register_button = page.get_by_role("button", name="Register instead")
                    assert register_button.evaluate(
                        "element => getComputedStyle(element).backgroundColor"
                    ) == "rgb(228, 234, 245)", "Login scoped secondary button style missing"
                    assert page.evaluate(
                        "getComputedStyle(document.body).margin === '0px'"
                    ), "Shell document reset missing"
                    register_button.click()
                    page.get_by_label("Name", exact=True).fill("Course Learner")
                    page.get_by_label("Email", exact=True).fill(
                        f"learner{width}@example.test"
                    )
                    page.get_by_label("Password", exact=True).fill("SmokeAa1!" + secrets.token_urlsafe(24))
                    page.get_by_role(
                        "button", name="Create account", exact=True
                    ).click()
                    try:
                        page.wait_for_url("**/projects", timeout=8000)
                    except Exception:
                        print("Browser errors:", errors)
                        print("URL:", page.url)
                        print("Page:", page.locator("body").inner_text())
                        raise
                    page.get_by_label("Name", exact=True).fill("Browser project")
                    page.get_by_label("Description", exact=True).fill(
                        "Verified in the browser"
                    )
                    page.get_by_role(
                        "button", name="Create project", exact=True
                    ).click()
                    page.get_by_role("link", name="Browser project").click()
                    page.get_by_role(
                        "heading", name="Browser project", exact=True
                    ).wait_for()
                    add_task_button = page.get_by_role("button", name="Add task", exact=True)
                    assert add_task_button.evaluate(
                        "element => getComputedStyle(element).backgroundColor"
                    ) == "rgb(37, 99, 235)", "TaskForm must own its scoped button style"
                    assert page.evaluate("""() => {
                        const probe = document.createElement('button');
                        probe.textContent = 'Unscoped probe';
                        document.body.appendChild(probe);
                        const colour = getComputedStyle(probe).backgroundColor;
                        probe.remove();
                        return colour !== 'rgb(37, 99, 235)';
                    }"""), "Component button styles leaked outside their scope"
                    assert not any('/assets/app.css' in entry['name'] for entry in
                        page.evaluate("performance.getEntriesByType('resource').map(entry => ({name: entry.name}))")
                    ), "The SPA must not load a shared app.css stylesheet"
                    page.get_by_label("Task title").fill("Record the lesson")
                    page.get_by_role("button", name="Add task", exact=True).click()
                    page.get_by_text("Record the lesson", exact=True).wait_for()
                    page.get_by_label("Task status", exact=True).select_option("done")
                    page.get_by_text("100% complete", exact=True).wait_for()
                    screenshot_dir = os.getenv("COURSE_SCREENSHOT_DIR")
                    if screenshot_dir:
                        Path(screenshot_dir).mkdir(parents=True, exist_ok=True)
                        page.screenshot(path=str(Path(screenshot_dir) / f"project-{width}.png"), full_page=True)
                    page.get_by_label("Filter tasks").select_option("todo")
                    page.get_by_text(
                        "No tasks match this filter.", exact=True
                    ).wait_for()
                    page.get_by_label("Filter tasks").select_option("all")
                    page.evaluate("window.courseNavigationMarker = 42")
                    page.get_by_role("link", name="Help", exact=True).click()
                    page.get_by_role("heading", name="Help centre").wait_for()
                    assert (
                        page.evaluate("window.courseNavigationMarker") == 42
                    ), "SPA link reloaded the document"
                    page.get_by_role("link", name="Browser help guide").click()
                    page.get_by_role(
                        "heading", name="Browser help guide", exact=True
                    ).wait_for()
                    page.get_by_text("Safe help content.", exact=True).wait_for()
                    page.go_back()
                    page.get_by_role("heading", name="Help centre").wait_for()
                    page.go_back()
                    page.get_by_role(
                        "heading", name="Browser project", exact=True
                    ).wait_for()
                    page.reload()
                    page.get_by_text("100% complete", exact=True).wait_for()
                    assert page.evaluate(
                        "document.documentElement.scrollWidth <= innerWidth"
                    ), "Mobile overflow"
                    page.get_by_role("link", name="Account", exact=True).click()
                    page.get_by_role("button", name="Sign out").click()
                    page.get_by_text("Signed out.", exact=True).wait_for()
                    assert page.request.get(BASE + "/api/projects/").status == 401
                    assert not errors, errors
                    context.close()
                    print(
                        f"{'Production MinifyJS' if production else 'Development'} browser workflow passed at {width}px"
                    )
                browser.close()
        finally:
            server.terminate()
            server.wait(timeout=10)
            log.close()


if __name__ == "__main__":
    main()
```

**Say:** "This file owns the feature named by its module or component. Read the route/form flow before continuing."

**Type:** Follow the action above in `scripts/browser_smoke.py`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Run and check

```bash
python -m playwright install chromium
python -m pytest -q
python scripts/browser_smoke.py
python scripts/browser_smoke.py --production
```

## Expected result

All application tests and both browser modes pass. Test data stays outside your learner database. No browser console errors occur in the verified workflow.

## Common errors

Install Chromium before Playwright. Stop other servers using port 8123. Loopback production smoke verifies assets and behavior, not cloud infrastructure or TLS.

## Short exercise

Complete the workflow using only the keyboard and check one layout change at both desktop and mobile sizes.

## What to say and show

Say: "Exercise customer, staff and CMS workflows in a real browser. The server remains responsible for persistence and authorization; the browser presents the result."

Show the expected result above, then point to the file responsible for it. Run the chapter check before the next lesson.

## Save your checkpoint

```bash
git add .
git commit -m "Complete chapter 14: Full-stack verification"
```

## Type-check before shipping

```bash
python management.py typecheck
python management.py typecheck --json
```

Checking inherits `tsconfig.json` when present. Static TypeScript child props are checked, while dynamic component contracts and some template cases remain limited. Passing these checks complements API and browser tests; it does not replace them.

### Current checker limitations in this course

The completed course is not yet type-clean on the 0.3.0 adapter. Its existing components report strict typing issues (including nullable state and empty-array inference), and the adapter does not fully model `$emit`, `emits`, automatic signal helpers, or all template narrowing. The command currently exits with findings; do not present it as a passing release gate for this unmodified course. Fix application typing issues and track the unsupported adapter cases separately. Keep API tests, browser tests and the production build as required checks. This limitation does not stop the development server. The simpler guessing-game example passes the published checker.
