From 725af9fac1b5cf09c1dae1d7812e02433f19f058 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 1 Sep 2026 03:51:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(docs):=20save=20chat=20answers=20as=20docs?= =?UTF-8?q?=20=E2=80=94=20edit=20screen,=20commit=20+=20push=20to=20the=20?= =?UTF-8?q?.env=20docs=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 10 + Containerfile | 3 +- alembic/versions/0011_doc_drafts.py | 79 +++ app/api/config.py | 17 +- app/api/doc_drafts.py | 292 +++++++++++ app/config.py | 57 ++- app/core/caching.py | 2 + app/core/docs_push.py | 164 ++++++ app/main.py | 2 + app/models.py | 66 +++ app/schemas.py | 69 +++ frontend/assets/app.js | 149 +++++- frontend/assets/brand.js | 178 ++++--- frontend/assets/doc-edit.js | 287 +++++++++++ frontend/assets/styles.css | 191 +++++++ frontend/doc-edit.html | 134 +++++ scripts/git_sync.py | 22 +- tests/e2e/test_cache_busting.py | 9 +- tests/e2e/test_configurable_brand.py | 6 +- tests/e2e/test_response_to_docs.py | 602 +++++++++++++++++++++++ tests/integration/test_api.py | 32 +- tests/integration/test_doc_drafts_api.py | 536 ++++++++++++++++++++ tests/integration/test_migration_0011.py | 322 ++++++++++++ tests/unit/test_caching.py | 1 + tests/unit/test_config.py | 118 +++++ tests/unit/test_doc_edit_screen.py | 545 ++++++++++++++++++++ tests/unit/test_docs_push.py | 200 ++++++++ tests/unit/test_frontend_brand.py | 1 + tests/unit/test_git_sync.py | 6 +- tests/unit/test_models.py | 33 ++ tests/unit/test_save_as_doc_button.py | 303 ++++++++++++ tests/unit/test_wide_column_css.py | 27 +- 32 files changed, 4356 insertions(+), 107 deletions(-) create mode 100644 alembic/versions/0011_doc_drafts.py create mode 100644 app/api/doc_drafts.py create mode 100644 app/core/docs_push.py create mode 100644 frontend/assets/doc-edit.js create mode 100644 frontend/doc-edit.html create mode 100644 tests/e2e/test_response_to_docs.py create mode 100644 tests/integration/test_doc_drafts_api.py create mode 100644 tests/integration/test_migration_0011.py create mode 100644 tests/unit/test_doc_edit_screen.py create mode 100644 tests/unit/test_docs_push.py create mode 100644 tests/unit/test_save_as_doc_button.py diff --git a/.env.example b/.env.example index da6cd33..a7ffd18 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,16 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume, # BOR_UPLOAD_MAX_MB=512 # caps BOTH the compressed upload and the total # extracted bytes (zip-bomb guard); must be > 0 +# --- Docs push (phase 59: save a chat answer as documentation) --- +# The git repo chat answers can be committed to — any remote (URL or +# local path). While empty, the "Save as doc" action is hidden and the +# push endpoint 409s. Commits land on BOR_DOCS_BRANCH (push --ff-only); +# open the PR yourself. +# BOR_DOCS_REPO=/path/to/docs-repo +# BOR_DOCS_BRANCH=bor-docs +# BOR_DOCS_BASE_BRANCH=main +# BOR_DOCS_WORK_DIR=~/bor-docs + # --- Admin & sign-in (single-admin password login; BOTH required) --- # The app refuses to start while either is empty (names the missing # variable(s) — README "Admin & sign-in"). Generate the secret with: diff --git a/Containerfile b/Containerfile index c2ba681..70b21a1 100644 --- a/Containerfile +++ b/Containerfile @@ -22,10 +22,11 @@ RUN mkdir -p /out/assets \ && esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \ && esbuild ./assets/history.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/history.js \ && esbuild ./assets/shared.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/shared.js \ + && esbuild ./assets/doc-edit.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/doc-edit.js \ && esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \ && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \ && esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \ - && cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html /out/ + && cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/ # ---------- Stage 2: python dependencies ---------- FROM docker.io/python:3.12-slim AS python diff --git a/alembic/versions/0011_doc_drafts.py b/alembic/versions/0011_doc_drafts.py new file mode 100644 index 0000000..a2c00f1 --- /dev/null +++ b/alembic/versions/0011_doc_drafts.py @@ -0,0 +1,79 @@ +"""doc_drafts: server-side drafts of chat answers saved as docs (phase 59) + +Revision ID: 0011 +Revises: 0010 +Create Date: 2026-09-01 + +Phase 59 (save chat answers as docs — edit screen → commit + push to the +.env docs branch, owner revision D3 2026-08-31: no PR tooling — one +additive, reversible table, no other schema change, A13): + +* ``doc_drafts`` — one row per "Save as doc" action: the long answer + body must live on the **server**, never in a URL. ``token`` is an + unguessable 128-bit ``uuid4`` — the edit screen's URL credential + (``/doc-edit.html?draft=``, the share-token trust model, + phase 51) — UNIQUE (``ix_doc_drafts_token``) + NOT NULL. Unlike the + NULLable ``saved_chats.share_token`` there is no "un-drafted" state, + so no NULLs ever occur; the unique index is the guard against + duplicate tokens. +* ``title`` / ``path`` / ``body`` — the editable triple (TEXT NOT NULL; + the body is the answer's raw markdown, never HTML — the + ``bor.chat.v1`` record shape). +* ``status`` — plain TEXT + server default ``'draft'`` (the + ``git_sources.kind`` phase-38 precedent — the ``draft`` | ``pushed`` + domain is enforced by the API layer, not a CHECK constraint). +* ``branch`` / ``commit_sha`` — TEXT NULL: set by the push endpoint + (task 04) when it commits + pushes the file to the ``BOR_DOCS_REPO`` + branch, recording the branch + the pushed branch's new HEAD (the UI's + branch + sha feedback). +* ``created_at`` / ``updated_at`` — TIMESTAMPTZ NOT NULL, stamped + server-side (``updated_at`` bumps on every row update via the ORM + ``onupdate`` — the ``saved_chats`` precedent). +""" +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision = "0011" +down_revision = "0010" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "doc_drafts", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("token", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("path", sa.Text(), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False, server_default=sa.text("'draft'")), + sa.Column("branch", sa.Text(), nullable=True), + sa.Column("commit_sha", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + # The token is the URL credential — a unique handle (the + # saved_chats.share_token unique-index precedent, phase 51). + op.create_index("ix_doc_drafts_token", "doc_drafts", ["token"], unique=True) + + +def downgrade() -> None: + # Safe order: drop the token index first, then the table (A13 — + # fully reversible, no other schema change). + op.drop_index("ix_doc_drafts_token", table_name="doc_drafts") + op.drop_table("doc_drafts") diff --git a/app/api/config.py b/app/api/config.py index 5b3a6dc..7d2f061 100644 --- a/app/api/config.py +++ b/app/api/config.py @@ -1,4 +1,5 @@ -"""Public app metadata (display name + version) for the frontend brand layer.""" +"""Public app metadata (display name + version) for the frontend brand +layer, plus the phase-59 docs-push flag (the "Save as doc" gating).""" from __future__ import annotations from fastapi import APIRouter, Depends @@ -9,6 +10,14 @@ router = APIRouter(tags=["config"]) @router.get("/config") -def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str]: # noqa: B008 - """Public app metadata for the frontend brand layer (phase 39).""" - return {"app_name": settings.app_name, "version": settings.app_version} +def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008 + """Public app metadata for the frontend brand layer (phase 39) + + the phase-59 ``docs_repo_configured`` flag — the chat page's + "Save as doc" button gating, surfaced the way ``app_name`` is + (the SAME boot fetch, no new network surface). Inert false while + ``BOR_DOCS_REPO`` is empty (the feature is off, D3).""" + return { + "app_name": settings.app_name, + "version": settings.app_version, + "docs_repo_configured": settings.docs_configured, + } diff --git a/app/api/doc_drafts.py b/app/api/doc_drafts.py new file mode 100644 index 0000000..6a20135 --- /dev/null +++ b/app/api/doc_drafts.py @@ -0,0 +1,292 @@ +"""Doc-drafts API — the draft lifecycle the edit screen runs on +(phase 59, task 02). + +The "Save as doc" action (task 05) POSTs a completed answer here, the +edit screen (task 06) GETs/PUTs it by token, and the push endpoint +(task 04) commits + pushes it. A draft's long answer body lives on the +**server** — never in a URL: the row is keyed by an unguessable 128-bit +``uuid4`` ``token`` (the edit screen's URL credential, +``/doc-edit.html?draft=`` — the share-token trust model, +phase 51). + +The whole router sits behind :func:`app.core.auth.require_admin` +(router-wide ``dependencies`` — the :mod:`app.api.steering` pattern): +drafts are admin-only, so anonymous callers get 403 on every route. + +Routes: ``POST /api/doc-drafts`` (create — 201, ``token = uuid4``, +``status = "draft"``), ``GET /api/doc-drafts/{token}`` (fetch by +token — 404 ``draft not found`` when unknown), ``PUT +/api/doc-drafts/{token}`` (partial update — absent fields unchanged, +``updated_at`` bumped; editing a ``pushed`` draft resets ``status`` +back to ``draft`` — the stored sha no longer describes the current +body, so the next push re-commits; phase 59 D3 ASSUMPTION), ``POST +/api/doc-drafts/{token}/push`` (the single mutation the edit screen +triggers — commit + ``git push`` the draft's file to the +``BOR_DOCS_REPO`` ``BOR_DOCS_BRANCH`` via +:func:`app.core.docs_push.push_document`; success records +``status`` / ``branch`` / ``commit_sha`` on the row and returns +``DocDraftPushed``; 409 while unconfigured, 422 on a path that no +longer passes the guard-rails, 502 on git failure with git's stderr +in the detail — the row untouched). + +Every ``path`` (create, update **and** push) passes the shared +:func:`validate_draft_path` guard, so no draft can ever be created, +edited or pushed with a path that escapes the repo root. +""" +from __future__ import annotations + +import uuid +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, text +from sqlalchemy.orm import Session + +from app.config import Settings, get_settings +from app.core.auth import require_admin +from app.core.docs_push import DocsPushError, push_document +from app.db import get_db +from app.models import DocDraft +from app.schemas import DocDraft as DocDraftOut +from app.schemas import ( + DocDraftCreate, + DocDraftPushed, + DocDraftUpdate, +) + +router = APIRouter( + prefix="/doc-drafts", + tags=["doc-drafts"], + dependencies=[Depends(require_admin)], # phase 59: drafts are admin-only +) + + +def validate_draft_path(raw: str) -> str: + """Guard-rail an in-repo doc path (phase 59, task 02). + + Shared by the create/update routes **and** the push endpoint (task + 04): the file will later be written inside the ``BOR_DOCS_REPO`` + checkout, so a path that escapes the repo root is a security hole, + not a typo. Rules (first violation wins, each 422 names its rule): + + * non-empty after strip — a blank path names no file; + * not absolute — ``/etc/passwd`` would leave the checkout; + * no ``.``/``..`` components — ``../x.md`` and ``a/b/../c.md`` walk + out of the checkout (checked via ``Path(p).parts``, so the + traversal is rejected wherever it sits); + * carries a file suffix (``Path(p).suffix`` non-empty) — the + committed file must be a real file, e.g. ``docs/note.md``. + + Returns the stripped path (the value that gets stored); raises + :class:`fastapi.HTTPException` (422) on the first violated rule. + """ + path = raw.strip() + if not path: + raise HTTPException(status_code=422, detail="path must not be empty") + if Path(path).is_absolute(): + raise HTTPException(status_code=422, detail="path must not be an absolute path") + if any(part in (".", "..") for part in Path(path).parts): + raise HTTPException( + status_code=422, + detail="path must not contain '.' or '..' path components", + ) + if not Path(path).suffix: + raise HTTPException( + status_code=422, detail="path must carry a file suffix (e.g. docs/note.md)" + ) + return path + + +def _to_out(row: DocDraft) -> DocDraftOut: + """The full-payload response shape (create/get/put).""" + return DocDraftOut( + token=row.token, + title=row.title, + path=row.path, + body=row.body, + status=row.status, + branch=row.branch, + commit_sha=row.commit_sha, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _get_draft_or_404(db: Session, token: uuid.UUID) -> DocDraft: + """One draft by its URL credential; 404 when the token is unknown.""" + row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().first() + if row is None: + raise HTTPException(status_code=404, detail="draft not found") + return row + + +@router.post("", response_model=DocDraftOut, status_code=201) +def create_draft( + payload: DocDraftCreate, + db: Session = Depends(get_db), # noqa: B008 +) -> DocDraftOut: + """Store one completed answer as a draft (201). + + ``title`` / ``body`` must be non-empty after strip (422 — the + ``SteeringNoteIn`` pattern; pydantic's ``min_length=1`` alone would + let a whitespace-only value through). ``path`` passes + :func:`validate_draft_path` (422 naming the violated rule). + ``token`` is a fresh ``uuid4`` — set on the PENDING row, so it ships + in the same INSERT (the ``share_token`` precedent, phase 51); + ``status`` starts as ``draft``. + """ + title = payload.title.strip() + if not title: + raise HTTPException(status_code=422, detail="title must not be empty") + body = payload.body.strip() + if not body: + raise HTTPException(status_code=422, detail="body must not be empty") + path = validate_draft_path(payload.path) + + row = DocDraft( + token=uuid.uuid4(), # the URL credential — set before INSERT + title=title, + path=path, + body=body, + ) + db.add(row) + db.commit() + db.refresh(row) + return _to_out(row) + + +@router.get("/{token}", response_model=DocDraftOut) +def get_draft( + token: uuid.UUID, + db: Session = Depends(get_db), # noqa: B008 +) -> DocDraftOut: + """One draft by its URL credential (the edit screen's load); 404 + ``draft not found`` when the token is unknown (no enumeration — one + message for every unknown token).""" + return _to_out(_get_draft_or_404(db, token)) + + +@router.put("/{token}", response_model=DocDraftOut) +def update_draft( + token: uuid.UUID, + payload: DocDraftUpdate, + db: Session = Depends(get_db), # noqa: B008 +) -> DocDraftOut: + """Partial edit of a draft (the edit screen's Save); 404 when the + token is unknown. + + Each field is replaced only when supplied (absent keeps the row's + current value; present must be non-empty after strip — 422). A + supplied ``path`` re-runs :func:`validate_draft_path`; the same + guard-rails apply on update as on create. ``updated_at`` is bumped + on every PUT — via the model's ``onupdate=func.now()`` when a stored + value changes, via an explicit raw ``UPDATE`` when the PUT is a + no-op (empty body, or every supplied value identical — the ORM + flushes nothing, so the onupdate default never fires). + + Editing a ``pushed`` draft resets ``status`` to ``draft`` (phase 59 + D3 ASSUMPTION): the stored ``commit_sha`` no longer describes the + current body, so the next push re-commits. The last push's + ``branch``/``commit_sha`` stay visible (the previous state) until + the next push overwrites them. + """ + row = _get_draft_or_404(db, token) + + # Validate everything BEFORE assigning anything, so a 422 on one + # field can never leave a half-applied edit pending. + new_title = payload.title.strip() if payload.title is not None else None + if payload.title is not None and not new_title: + raise HTTPException(status_code=422, detail="title must not be empty") + new_path = validate_draft_path(payload.path) if payload.path is not None else None + new_body = payload.body.strip() if payload.body is not None else None + if payload.body is not None and not new_body: + raise HTTPException(status_code=422, detail="body must not be empty") + + changed = False + if new_title is not None and row.title != new_title: + row.title = new_title + changed = True + if new_path is not None and row.path != new_path: + row.path = new_path + changed = True + if new_body is not None and row.body != new_body: + row.body = new_body + changed = True + if row.status == "pushed": + # D3 ASSUMPTION — see the docstring above. + row.status = "draft" + changed = True + + if not changed: + # No-op PUT: the onupdate default does not fire for a flush that + # changed nothing, so bump updated_at explicitly (raw SQL — the + # ORM object is already in its final state). + db.execute( + text("UPDATE doc_drafts SET updated_at = now() WHERE id = :id"), + {"id": row.id}, + ) + db.commit() + db.refresh(row) + return _to_out(row) + + +@router.post("/{token}/push", response_model=DocDraftPushed) +def push_doc_draft( + token: uuid.UUID, + db: Session = Depends(get_db), # noqa: B008 + settings: Settings = Depends(get_settings), # noqa: B008 +) -> DocDraftPushed: + """Commit + push the draft's file (phase 59, task 04). + + The single mutation the edit screen (task 06) triggers: take the + draft's (title, path, body), commit + push through + :func:`app.core.docs_push.push_document`, and record the outcome + on the draft. Outcomes (checked in this order): + + 1. unknown token → 404 ``draft not found`` (same message as GET/ + PUT — no enumeration); + 2. ``settings.docs_configured`` false → 409 naming + ``BOR_DOCS_REPO`` (D3: the feature is inert by default — the + optional-feature pattern of the git-sources env fallback); + 3. the stored ``path`` re-runs :func:`validate_draft_path` → 422 + on the first violated rule (a row must not be pushable into a + bad path, whatever wrote it); + 4. :class:`DocsPushError` → 502 with ``detail=str(exc)`` — git's + stderr, the ``GitSyncError`` → ``detail`` mapping from + :mod:`app.api.git_sources`. Only a SUCCESS mutates the row: + the failed push leaves ``status`` / ``branch`` / ``commit_sha`` + exactly as found (no partial commit). + + On success the row becomes ``status = "pushed"`` with the landed + ``branch`` and ``commit_sha`` (must equal ``git rev-parse + `` in the repo) and a bumped ``updated_at`` (the column's + ``onupdate`` fires — a successful push always rewrites at least + ``commit_sha``, so a flush always happens); the response is + ``DocDraftPushed(status="pushed", branch, commit_sha=sha)`` — the + edit screen's branch + sha feedback (D3: no PR, no URL — the + owner opens the PR themselves). + """ + row = _get_draft_or_404(db, token) + if not settings.docs_configured: + raise HTTPException( + status_code=409, detail="docs repo not configured (BOR_DOCS_REPO)" + ) + path = validate_draft_path(row.path) + try: + branch, sha = push_document( + repo=settings.docs_repo, + base_branch=settings.docs_base_branch, + branch=settings.docs_branch, + work_dir=settings.docs_work_dir, + rel_path=path, + content=row.body, + commit_message=f"docs: {row.title}", + ) + except DocsPushError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from None + row.status = "pushed" + row.branch = branch + row.commit_sha = sha + db.commit() + db.refresh(row) + return DocDraftPushed(status="pushed", branch=branch, commit_sha=sha) diff --git a/app/config.py b/app/config.py index df5d757..ef245f6 100644 --- a/app/config.py +++ b/app/config.py @@ -9,7 +9,7 @@ import os import re from functools import lru_cache -from pydantic import field_validator +from pydantic import ValidationInfo, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict #: The built-in DEFAULT import formats (PLAN anchor A9, revised 2026-08-21; @@ -178,6 +178,30 @@ class Settings(BaseSettings): #: pattern). upload_max_mb: int = 512 + # --- Docs push (phase 59: save a chat answer as documentation) --- + #: The git repo a saved chat answer is committed to (phase 59, D3): + #: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a + #: local path (generic git remote — no ``gh``, no GitHub assumption). + #: While empty the feature is inert: the "Save as doc" action is + #: hidden and the push endpoint 409s (the optional-feature pattern of + #: the git-sources env fallback). + docs_repo: str = "" + #: The branch pushes land on (phase 59): each push cuts it fresh from + #: ``docs_base_branch`` and ``git push --ff-only``s it — the owner + #: opens the PR themselves (D3: no PR tooling). A git branch token, + #: so no whitespace and no ``..`` (the validator below — + #: all-or-nothing with ``docs_repo``). + docs_branch: str = "bor-docs" + #: The branch each push bases off (fetched/reset before the + #: ``checkout -B`` of ``docs_branch``). Same token shape rules as + #: ``docs_branch``. + docs_base_branch: str = "main" + #: Where ``docs_repo`` is checked out on the server. Raw string — + #: ``Path.expanduser()`` is applied by the push service, not here + #: (the ``sources_dir``/``upload_dir`` convention). Deliberately kept + #: separate from ``sources_dir`` (the source checkouts). + docs_work_dir: str = "~/bor-docs" + @field_validator("import_extensions") @classmethod def _import_extensions_known(cls, v: str) -> str: @@ -215,6 +239,28 @@ class Settings(BaseSettings): raise ValueError("upload_max_mb must be > 0 (MiB)") return v + @field_validator("docs_branch", "docs_base_branch") + @classmethod + def _docs_branch_tokens(cls, v: str, info: ValidationInfo) -> str: + """Git branch-token shape guard (phase 59, D3) — all-or-nothing: + while ``docs_repo`` is empty the feature is inert, so the + (ignored) branch values must not block startup; once a repo IS + set, a blank / whitespace-bearing / ``..``-bearing branch is a + typo that would corrupt a ``git checkout`` argument, so it fails + loudly at startup (the ``agent_max_rounds`` pattern), naming the + field.""" + repo = info.data.get("docs_repo") + if not isinstance(repo, str) or not repo.strip(): + return v + name = info.field_name or "docs branch" + if not v.strip(): + raise ValueError(f"{name} must not be empty while docs_repo is set") + if re.search(r"\s", v): + raise ValueError(f"{name} must not contain whitespace (a git branch token)") + if ".." in v: + raise ValueError(f"{name} must not contain '..' (a git branch token)") + return v + # Suggested questions (onboarding + empty state). suggestions: list[str] = [ "How is my Kubernetes cluster set up?", @@ -242,6 +288,15 @@ class Settings(BaseSettings): """ return [part.strip() for part in self.git_sources.split(",") if part.strip()] + @property + def docs_configured(self) -> bool: + """True while a docs repo is configured (phase 59): the "Save as + doc" surface is live. Empty (or whitespace-only) ``docs_repo`` + → the feature is inert — no button for anyone, the push + endpoint 409s (the optional-feature pattern of the git-sources + env fallback).""" + return bool(self.docs_repo.strip()) + @property def effective_api_key(self) -> str: """API key for aipi: explicit setting, then $AIPI_KEY, then a placeholder.""" diff --git a/app/core/caching.py b/app/core/caching.py index 72e9cbd..24975b2 100644 --- a/app/core/caching.py +++ b/app/core/caching.py @@ -147,6 +147,8 @@ HTML_PAGES: tuple[str, ...] = ( # dynamic /shared/ — both must carry the no-cache + ?v= # contract, so the direct URL can never pin stale assets). "/shared.html", + # phase 59: the doc edit screen (the flow page task 06 ships). + "/doc-edit.html", ) #: Prefix of the versioned static assets (header-only caching; the body is diff --git a/app/core/docs_push.py b/app/core/docs_push.py new file mode 100644 index 0000000..95b9604 --- /dev/null +++ b/app/core/docs_push.py @@ -0,0 +1,164 @@ +"""Docs-push service (phase 59, task 03). + +Commits a document into the configured docs repo and pushes it to the +configured branch. **D3 (owner-locked, 2026-08-31): push only** — the +flow ends at the push to the branch named in ``.env`` +(``BOR_DOCS_REPO`` + ``BOR_DOCS_BRANCH``); no PR is ever created or +attempted (no ``gh``, no PR URL, no URL construction, no +GitHub-specific logic). The remote is **generic** — a local path, +``https://``, ``ssh://``, any host — and git is invoked exclusively +through :func:`scripts.git_sync.run_git` (A11: stdlib ``subprocess`` +only, no new packages): :func:`push_document` is the only caller of +git for this feature besides :mod:`scripts.git_sync` itself. + +The push is a plain ``git push origin `` — git 2.55 no longer +accepts the ``--ff-only`` flag on ``push`` — and a push without +``-f`` / a ``+`` refspec already refuses non-fast-forward updates +(client-side, against the remote's live refs), so a +concurrently-advanced remote fails loudly: never a force-push, never +a merge. Every failure raises :class:`DocsPushError` carrying git's +stderr (the ``GitSyncError`` style), and the checkout under +``work_dir`` is left as-is for inspection. +""" +from __future__ import annotations + +from pathlib import Path + +from scripts.git_sync import GitSyncError, run_git + +__all__ = ["DocsPushError", "push_document"] + +# Fixed per-invocation commit identity (phase 59 ASSUMPTION) — passed +# with ``-c`` on the commit itself, so the push never relies on the +# machine's global git config. ``commit.gpgsign=false`` keeps a +# headless server from ever prompting for a GPG pinentry (this repo's +# own no-GPG-signing policy). +_COMMIT_NAME = "Brain of Reese" +_COMMIT_EMAIL = "bor@local" + + +class DocsPushError(RuntimeError): + """A docs push failed (or git is missing); carries git's stderr.""" + + +def _refuse_unsafe_rel_path(rel_path: str) -> None: + """Defensively re-assert the upstream path guard-rails (task 02). + + The file is written inside the docs checkout, so a blank path, an + absolute path, or any ``.``/``..`` component is refused here as + well — even though the drafts API already guard-railed the value. + """ + rel = Path(rel_path) + if not rel_path.strip() or rel.is_absolute() or any(part in (".", "..") for part in rel.parts): + raise DocsPushError(f"refusing unsafe rel_path: {rel_path!r}") + + +def push_document( + repo: str, + base_branch: str, + branch: str, + work_dir: str, + rel_path: str, + content: str, + commit_message: str, +) -> tuple[str, str]: + """Commit *content* at *rel_path* and push (ff-only) to *branch*. + + Step sequence (each step via :func:`scripts.git_sync.run_git`; + ``cwd=work_dir`` once the checkout exists): + + 1. Ensure the checkout: without ``.git`` → + ``git clone --depth 1 --branch `` + (the base branch is explicit — the remote's default may differ). + 2. ``git fetch --depth 1 origin `` — re-sync the base + before every push. The fetched base tip (``FETCH_HEAD``) is + captured immediately — a later *failed* fetch clears + ``FETCH_HEAD``. + 3. ``git fetch --depth 100 origin `` — **failure is + expected** while the branch does not exist on the remote yet + (swallowed and continued); on success the fetched branch tip is + captured the same way. + 4. Attach the local branch: + + - a local branch left by a previous push → ``git checkout + `` — keep its own history, so a + concurrently-advanced remote then fails the push loudly in + step 7 instead of being silently re-based or merged; + - otherwise ``git checkout -B `` onto the captured + branch tip (re-attach onto the previously pushed branch — its + history, so the push can fast-forward) or, on first push, + onto the captured base tip (a new branch from the fresh + base). The captured shas are used instead of symbolic refs: + git's local transport (a local-path remote) does not create + ``refs/remotes/origin/`` for a newly fetched branch, + and a failed fetch clears ``FETCH_HEAD``. + 5. Write the file (parent dirs created; + :func:`_refuse_unsafe_rel_path` re-asserts the parts check + defensively). + 6. ``git add -- `` + ``commit -m `` with + the fixed per-invocation identity (``-c user.name`` / + ``-c user.email`` — no reliance on global git config). + 7. ``git push origin `` — creates the remote branch on + first push; a concurrently-advanced remote is refused + non-fast-forward (never a force-push, never a merge). + 8. ``sha = git rev-parse HEAD``; return ``(branch, sha)``. + + Raises :class:`DocsPushError` (git's stderr in the message) on any + failure — the checkout is left as-is for inspection. + """ + _refuse_unsafe_rel_path(rel_path) + work = Path(work_dir).expanduser() + try: + if not (work / ".git").exists(): + work.parent.mkdir(parents=True, exist_ok=True) + run_git( + [ + "git", + "clone", + "--depth", + "1", + "--branch", + base_branch, + repo, + str(work), + ], + cwd=work.parent, + ) + run_git(["git", "fetch", "--depth", "1", "origin", base_branch], cwd=work) + # Capture the fetched base tip now — a *failed* fetch clears + # FETCH_HEAD, so the symbolic ref cannot be reused later. + base_sha = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=work).strip() + try: + run_git(["git", "fetch", "--depth", "100", "origin", branch], cwd=work) + branch_sha = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=work).strip() + except GitSyncError: + branch_sha = None # the branch does not exist on the remote yet + if run_git(["git", "branch", "--list", branch], cwd=work).strip(): + run_git(["git", "checkout", branch], cwd=work) + else: + anchor = branch_sha if branch_sha is not None else base_sha + run_git(["git", "checkout", "-B", branch, anchor], cwd=work) + target = work / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + run_git(["git", "add", "--", rel_path], cwd=work) + run_git( + [ + "git", + "-c", + f"user.name={_COMMIT_NAME}", + "-c", + f"user.email={_COMMIT_EMAIL}", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + commit_message, + ], + cwd=work, + ) + run_git(["git", "push", "origin", branch], cwd=work) + sha = run_git(["git", "rev-parse", "HEAD"], cwd=work).strip() + except GitSyncError as err: + raise DocsPushError(str(err)) from err + return branch, sha diff --git a/app/main.py b/app/main.py index 03215e7..e19a609 100644 --- a/app/main.py +++ b/app/main.py @@ -31,6 +31,7 @@ from app.api.chats import ( shared_page_router as chats_shared_page_router, ) from app.api.config import router as config_router +from app.api.doc_drafts import router as doc_drafts_router from app.api.docs import router as docs_router from app.api.git_sources import router as git_sources_router from app.api.health import router as health_router @@ -81,6 +82,7 @@ def create_app() -> FastAPI: app.include_router(steering_router, prefix="/api") app.include_router(sync_router, prefix="/api") app.include_router(chats_router, prefix="/api") + app.include_router(doc_drafts_router, prefix="/api") # Phase 51: the anonymous shared-chat read — NO admin dependency. # /api/shared/ is the JSON snapshot; /shared/ (the # page route below, registered without a prefix) is the page. diff --git a/app/models.py b/app/models.py index fb75d40..65fdf9a 100644 --- a/app/models.py +++ b/app/models.py @@ -31,6 +31,16 @@ Data model — see ``.agent/PLAN.md`` §Data Model: generation of the knowledge base is current, bumped exactly once per KB-changing sync so saved chats can be marked stale (phase 53). +* ``doc_drafts`` — server-side drafts of chat answers saved as + documentation: one row per "Save as doc" action + (the long answer body lives here, never in a URL), + keyed by an unguessable ``uuid4`` ``token`` (the + edit screen's URL credential — the share-token + trust model, phase 51); ``status`` moves + ``draft`` → ``pushed`` (``branch`` + + ``commit_sha`` recorded) when the push endpoint + commits + pushes the file to the + ``BOR_DOCS_REPO`` branch (phase 59). """ from __future__ import annotations @@ -205,6 +215,62 @@ class GitSource(Base): added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) +class DocDraft(Base): + """One server-side draft of a chat answer saved as documentation + (phase 59, task 01). + + A long answer body must live on the **server**, never in a URL: the + "Save as doc" action POSTs the answer's raw markdown to + ``POST /api/doc-drafts`` (task 02), which stores it here and hands + back an unguessable 128-bit ``uuid4`` ``token`` — the edit + screen's URL credential (``/doc-edit.html?draft=``, the + share-token trust model, phase 51). ``status`` stays ``draft`` + until the push endpoint (task 04) commits + pushes the file to the + ``BOR_DOCS_REPO`` branch — then it is ``pushed``, with ``branch`` + and ``commit_sha`` recorded (the UI's branch + sha feedback; D3: + no PR tooling — the owner opens the PR themselves). + """ + + __tablename__ = "doc_drafts" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + #: The URL credential (``/doc-edit.html?draft=``): an + #: unguessable 128-bit ``uuid4`` — never the row id, never + #: sequential/guessable. Unique NOT NULL: unlike the NULLable + #: ``saved_chats.share_token`` there is no "un-drafted" state, so + #: NULLs never occur (always set on create). + token: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), unique=True, nullable=False, default=uuid.uuid4 + ) + #: The document's title. Defaults client-side to the last user + #: question (whitespace-collapsed, ≤120 chars — the chat auto-title + #: convention, phase 50); the edit screen changes anything. + title: Mapped[str] = mapped_column(Text) + #: The in-repo file path (default ``docs/.md``). Guard-railled + #: by the API layer (task 02 — repo-relative, no ``..``); the + #: column itself is plain TEXT (the ``documents.path`` precedent). + path: Mapped[str] = mapped_column(Text) + #: The markdown body — the answer's raw text (never HTML — the + #: ``bor.chat.v1`` record's ``text``), edited on the edit screen. + body: Mapped[str] = mapped_column(Text) + #: "draft" until the push endpoint commits + pushes the file, then + #: "pushed" — the domain is enforced by the API layer (the + #: ``git_sources.kind`` phase-38 precedent: plain TEXT + server + #: default, no CHECK constraint). + status: Mapped[str] = mapped_column(Text, default="draft", server_default="'draft'") + #: Set on push (task 04): the branch the commit landed on (the + #: ``BOR_DOCS_BRANCH`` name); NULL while still a draft. + branch: Mapped[str | None] = mapped_column(Text) + #: ... and the pushed branch's new HEAD sha (must equal + #: ``git rev-parse `` in the repo); NULL while still a + #: draft. + commit_sha: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + class SavedChat(Base): """One owner-saved chat conversation (phase 50). diff --git a/app/schemas.py b/app/schemas.py index 62ea9c0..c7d2a60 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -523,3 +523,72 @@ class UnshareOut(BaseModel): chat_id: uuid.UUID shared: bool + + +class DocDraftCreate(BaseModel): + """``POST /api/doc-drafts`` body (phase 59, task 02): one completed + chat answer about to become documentation. + + ``title`` arrives client-side as the last user question + (whitespace-collapsed, ≤120 chars — the chat auto-title convention, + phase 50); ``path`` as ``docs/.md``; ``body`` is the answer's + raw markdown (never HTML — the ``bor.chat.v1`` record's ``text``, + the phase-50/51 round-trip convention). The path guard-rails (task + 02 — repo-relative, no ``..``, no absolute path) run in the API + layer so the 422 details stay fixed strings; the max lengths mirror + the ``documents`` table (title 500, path 1000). + """ + + title: str = Field(min_length=1, max_length=500) + path: str = Field(min_length=1, max_length=1000) + body: str = Field(min_length=1) + + +class DocDraftUpdate(BaseModel): + """``PUT /api/doc-drafts/{token}`` body (phase 59, task 02): a + partial update — each field is replaced only when supplied (absent + keeps the row's current value; present must be non-empty — the + ``SavedChatUpdate`` optional-title pattern, extended to all three + editable fields). The same path guard-rails as create run in the + API layer when ``path`` is supplied. + """ + + title: str | None = Field(default=None, min_length=1, max_length=500) + path: str | None = Field(default=None, min_length=1, max_length=1000) + body: str | None = Field(default=None, min_length=1) + + +class DocDraft(BaseModel): + """One draft row, full payload (create/get/put response, phase 59). + + ``token`` is the URL credential (``/doc-edit.html?draft=`` + — the unguessable ``uuid4``, the share-token trust model, phase + 51). ``status`` is ``draft`` until the push endpoint commits + + pushes the file, then ``pushed`` with ``branch`` / ``commit_sha`` + recorded (both NULL while still a draft). Datetimes serialize + ISO-8601 on the wire (pydantic default). + """ + + token: uuid.UUID + title: str + path: str + body: str + status: str + branch: str | None = None + commit_sha: str | None = None + created_at: datetime + updated_at: datetime + + +class DocDraftPushed(BaseModel): + """``POST /api/doc-drafts/{token}/push`` success response (phase 59, + task 04): the commit + ``git push --ff-only`` landed — ``branch`` + is the ``BOR_DOCS_BRANCH`` name and ``commit_sha`` the pushed + branch's new HEAD (the edit screen's branch + sha feedback; it must + equal ``git rev-parse `` in the repo — the E2E source of + truth is the bare repo's state, not the UI alone). + """ + + status: Literal["pushed"] = "pushed" + branch: str + commit_sha: str diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 975636a..82cc04b 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -495,6 +495,126 @@ function markLastRetryable() { const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null; if (!prev || prev.who !== "user") return; appendRetryButton(lastBrainWrap); + // Phase 59: "Save as doc" stays the meta row's rightmost action — + // when the Retry button lands on the SAME bubble, re-append the save + // button after it (the auto margins split the free space between the + // right-aligned buttons; DOM order decides the right edge). + const saveDocBtn = lastBrainWrap.querySelector(".save-as-doc-btn"); + if (saveDocBtn && saveDocBtn.parentElement) + saveDocBtn.parentElement.appendChild(saveDocBtn); +} + +/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right + * "Save as doc" action of EVERY completed brain bubble (deflected + * included — same scope as Tune; a stopped partial is a note, not an + * answer, so m.stopped records never get it — the restore call site + * gates on it). Gate: admin (the whoami gate Tune uses) AND a + * configured docs repo (docsRepoConfigured — /api/config, settled in + * the boot IIFE before any bubble renders). `markdown` is the RAW + * persisted answer text — m.text on the restore path, the + * done/fallback raw text on the live path — NEVER the rendered HTML. + * The .save-as-doc-btn's margin-inline-start: auto pushes it to the + * row's right edge (the TODO's "bottom right"); markLastRetryable + * keeps it rightmost when the last bubble also carries the Retry + * button. + * + * Click: default title (the LAST user question, whitespace-collapsed, + * ≤120 chars — the phase-50 auto-title convention) + default in-repo + * path (docs/.md) → POST /api/doc-drafts {title, path, body} → + * 201 → /doc-edit.html?draft= (the edit screen, task 06, owns + * the rest). Failure → the neutral one-line banner (phase-55 + * convention), the conversation unblocked, no navigation. */ +const SAVE_AS_DOC_ICON = + ''; + +const DOC_TITLE_MAX = 120; // the phase-50 auto-title cap (owner-locked) + +/* The default doc title: the LAST user question's text, + * whitespace-collapsed, truncated to 120 chars — the phase-50 + * auto-title convention (server-side: " ".join(text.split())[:120]) + * applied to the last question. Defensive "Note" when the + * conversation has no user record (the UI cannot produce one). + * " ".join(split()) == replace(/\s+/g, " ").trim() for non-empty + * input; the trim keeps the leading/trailing-whitespace edge identical. */ +function defaultDocTitle() { + let question = ""; + for (let i = conversation.length - 1; i >= 0; i -= 1) { + if (conversation[i].who === "user") { + question = conversation[i].text; + break; + } + } + return question.replace(/\s+/g, " ").trim().slice(0, DOC_TITLE_MAX) || "Note"; +} + +/* The default in-repo path slug (phase 59 locked assumption): + * lowercase, runs of non-alphanumerics → "-", trimmed, ≤60 chars, + * empty → "note". The 60-cut can land mid dash-run — the trailing + * trim again keeps the path from ending in a dangling "-". */ +function docSlug(title) { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) + .replace(/-+$/g, ""); + return slug || "note"; +} + +/* The bottom-right "Save as doc" button — the appendTuneButton + * pattern: reuses the .msg-meta row when it exists (role=list → the + * button joins as a listitem so ARIA stays valid), otherwise creates + * a plain meta row; one button per bubble. */ +function appendSaveAsDocButton(wrap, markdown) { + if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured + const body = wrap.querySelector(".msg-body"); + if (!body) return; + let meta = body.querySelector(".msg-meta"); + if (!meta) { + meta = document.createElement("div"); + meta.className = "msg-meta"; + body.appendChild(meta); + } + if (meta.querySelector(".save-as-doc-btn")) return; // one per bubble + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right + if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem"); + btn.innerHTML = SAVE_AS_DOC_ICON + "Save as doc"; + btn.addEventListener("click", () => saveAsDoc(btn, markdown)); + meta.appendChild(btn); +} + +/* Create the draft from the bubble's RAW markdown and hand off to the + * edit screen. Double-click guard: one save at a time (the button is + * disabled until the outcome — released in the finally, never stale, + * PLAN §7.4). */ +async function saveAsDoc(btn, markdown) { + if (btn.disabled) return; // one save at a time (double-click guard) + btn.disabled = true; + try { + const title = defaultDocTitle(); + const path = `docs/${docSlug(title)}.md`; + const res = await fetch("/api/doc-drafts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, path, body: markdown }), + }); + if (!res.ok) { + // Neutral one-line copy (phase-55 convention) — the detail may + // be a guard-rail 422 or a server hiccup; neither is actionable + // here, and the conversation stays unblocked (no navigation). + showErrorBanner("Couldn't save the answer as a doc — try again."); + return; + } + const draft = await res.json(); + // 201: the draft's uuid4 token IS the edit screen's credential. + location.assign("/doc-edit.html?draft=" + draft.token); + } catch { + showErrorBanner("Couldn't save the answer as a doc — is the app reachable?"); + } finally { + btn.disabled = false; // released on EVERY outcome — never stale + } } /* Inline tuning form under the bubble: labeled textarea (maxlength 2000) @@ -1091,6 +1211,10 @@ function renderStoredMessage(m) { } appendSources(wrap, m.sources); appendTuneButton(wrap); // restored brain answers are tunable too + // Phase 59: the RAW persisted markdown (m.text — HTML is never + // persisted). A stopped partial (m.stopped) is a note, not an answer + // — no button (the live stop path adds none either). + if (!m.stopped) appendSaveAsDocButton(wrap, m.text); if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins } @@ -1538,6 +1662,16 @@ const signInLink = document.querySelector("#sign-in-link"); const signOutBtn = document.querySelector("#sign-out-btn"); let isAdmin = false; +/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push gate + * — GET /api/config's ``docs_repo_configured`` (settings.docs_configured + * server-side), surfaced by brand.js as window.BOR_DOCS_REPO_CONFIGURED + * (the way app_name is: a window global, false until the boot fetch + * proves otherwise). Captured ONCE in the boot IIFE after the fetch + * settles, so the "Save as doc" buttons render exactly once: present + * for a configured admin, absent for everyone else — and while + * BOR_DOCS_REPO is empty the feature is inert (D3). */ +let docsRepoConfigured = false; + function applyAuthState() { if (signInLink) signInLink.hidden = isAdmin; if (signOutBtn) signOutBtn.hidden = !isAdmin; @@ -1812,11 +1946,15 @@ async function runTurn(text, { reask = false } = {}) { appendMaybeTry(wrap, ev.suggestions); } appendSources(wrap, ev.sources); - appendTuneButton(wrap); // every completed brain bubble is tunable // Thinking-without-answer (reasoning can exhaust max_tokens): the // bubble gets the empty-answer fallback — what the user saw is // what gets persisted. const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : ""); + appendTuneButton(wrap); // every completed brain bubble is tunable + // Phase 59: the RAW persisted markdown (never the rendered + // HTML) — exactly the string rememberBrainTurn stores below, + // so a reload (the restore path) offers the identical draft. + appendSaveAsDocButton(wrap, finalText || acc || "…"); if (!acc && sawThinking) { wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText); } @@ -1851,6 +1989,7 @@ async function runTurn(text, { reask = false } = {}) { const fallback = EMPTY_ANSWER_FALLBACK; const fwrap = addMessage("brain", fallback); appendTuneButton(fwrap); + appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path rememberBrainTurn(fallback, {}); // persist what the user actually saw lastBrainWrap = fwrap; markLastRetryable(); // phase 49: the fallback bubble is retryable too @@ -1965,6 +2104,14 @@ window.addEventListener("pagehide", () => { (async () => { await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami + // Phase 59: /api/config is settled BEFORE any bubble renders — + // brand.js's single boot fetch (window.BOR_CONFIG_PROMISE, never + // rejecting) has set window.BOR_DOCS_REPO_CONFIGURED (false until + // proven), so a restored conversation of a configured admin gets the + // "Save as doc" button exactly once: no flash, no re-render, no + // second fetch (the brand fetch IS the config fetch). + await (window.BOR_CONFIG_PROMISE ?? Promise.resolve()); + docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true; applyAuthState(); // chat page: the auth pair (idempotent with header.js) // Phase 55 (task 03): no Share-reveal step — the pill is static, // always-visible markup (visible to every visitor, phase 51 contract). diff --git a/frontend/assets/brand.js b/frontend/assets/brand.js index 308cc66..0189e13 100644 --- a/frontend/assets/brand.js +++ b/frontend/assets/brand.js @@ -9,7 +9,15 @@ * Contract (phase 39 locked decisions — A11 no CDN, runtime fetch): * • window.BOR_BRAND = "Brain of Reese" synchronously — the default * name renders immediately, no blank flash; - * • fetch("/api/config", { cache: "no-store" }) — on success with a + * • Phase 59: window.BOR_DOCS_REPO_CONFIGURED = false synchronously + * (inert until proven) and window.BOR_CONFIG_PROMISE — the SAME + * fetch's promise, exposed at parse time so the chat page's boot + * (app.js) can await it BEFORE rendering any bubble; the "Save as + * doc" gating flag is then final, and a restored conversation of a + * configured admin never misses (or flashes) the button. The + * promise NEVER rejects — the error arm warns and resolves null; + * • fetch("/api/config", { cache: "no-store" }) — on success the + * docs flag is set from cfg.docs_repo_configured, and on a * non-empty app_name, window.BOR_BRAND is updated and the name is * applied to the DOM: * 1. document.title — global replace of the literal; @@ -36,6 +44,12 @@ reading window.BOR_BRAND at evaluation time always find a value. */ window.BOR_BRAND = "Brain of Reese"; +/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push flag — + surfaced the way app_name is (a window global, inert until the boot + fetch proves otherwise). false = the "Save as doc" action is hidden + for everyone (BOR_DOCS_REPO empty — the feature is off). */ +window.BOR_DOCS_REPO_CONFIGURED = false; + /* The literal the DOM passes replace — the default name. The page scripts' own `window.BOR_BRAND || "Brain of Reese"` fallbacks stay in sync with it. */ @@ -50,83 +64,101 @@ function escapeHTML(s) { }[c])); } -function applyBrand() { - fetch("/api/config", { cache: "no-store" }) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) - .then((cfg) => { - const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : ""; - if (!name) return; // empty / missing: the default stands - window.BOR_BRAND = name; - - // 1. The document title (global replace of the literal — covers - // every page's static "<…> · Brain of Reese" titles). - document.title = document.title.replaceAll(BRAND_LITERAL, name); - - // 2. The header brand on every page: a name starting "Brain of " - // keeps the bold split (the current look), anything else - // renders plain — the name is always escaped. - for (const el of document.querySelectorAll(".brand-text")) { - if (name.startsWith("Brain of ")) { - const rest = name.slice("Brain of ".length); - el.innerHTML = `Brain of ${escapeHTML(rest)}`; - } else { - el.textContent = name; - } - } - - // 3. Prose: a TreeWalker over the body's text nodes replaces the - // literal (the empty-state h1, any other copy). Text nodes - // inside + + + diff --git a/scripts/git_sync.py b/scripts/git_sync.py index 1c5afc2..20c8f7c 100644 --- a/scripts/git_sync.py +++ b/scripts/git_sync.py @@ -10,14 +10,16 @@ No credentials are stored here; whatever the URL/SSH config supplies is used. This module is the only place the ``git`` CLI is invoked (A11: stdlib -``subprocess`` only, no new packages). +``subprocess`` only, no new packages) — every git command goes through +:func:`run_git`: the clone/pull in :func:`clone_or_pull` and the +docs-push sequence in :mod:`app.core.docs_push` (phase 59). """ from __future__ import annotations import subprocess from pathlib import Path -__all__ = ["GitSyncError", "clone_or_pull"] +__all__ = ["GitSyncError", "clone_or_pull", "run_git"] class GitSyncError(RuntimeError): @@ -40,14 +42,22 @@ def clone_or_pull(url: str, dest: Path | str) -> Path: dest = Path(dest) if not dest.exists() or not (dest / ".git").exists(): dest.parent.mkdir(parents=True, exist_ok=True) - _run(["git", "clone", "--depth", "1", url, str(dest)], cwd=dest.parent) + run_git(["git", "clone", "--depth", "1", url, str(dest)], cwd=dest.parent) else: - _run(["git", "pull", "--ff-only"], cwd=dest) + run_git(["git", "pull", "--ff-only"], cwd=dest) return dest -def _run(argv: list[str], cwd: Path) -> str: - """Run a git command, capturing output; raise GitSyncError on failure.""" +def run_git(argv: list[str], cwd: Path) -> str: + """Run one git command, capturing output; raise GitSyncError on failure. + + The single ``git`` invocation point for the whole app (A11). Every + step of :func:`clone_or_pull` and of the docs-push sequence + (:mod:`app.core.docs_push`, phase 59) goes through here, so error + handling stays uniform: captured stdout on success, and + :class:`GitSyncError` carrying git's stderr on a non-zero exit (or + when the git binary is missing from PATH). + """ try: proc = subprocess.run(argv, cwd=cwd, capture_output=True, text=True) except FileNotFoundError: diff --git a/tests/e2e/test_cache_busting.py b/tests/e2e/test_cache_busting.py index 34a24da..b27f40d 100644 --- a/tests/e2e/test_cache_busting.py +++ b/tests/e2e/test_cache_busting.py @@ -116,9 +116,9 @@ def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None def test_other_pages_share_the_token(page: Page, app_url: str) -> None: - """/sources.html, /login.html and /history.html (phase 50): each - document revalidates, and all three pages' stylesheet requests carry - the same process token.""" + """/sources.html, /login.html, /history.html (phase 50) and + /doc-edit.html (phase 59): each document revalidates, and all four + pages' stylesheet requests carry the same process token.""" token = _expected_token() assert token @@ -134,7 +134,8 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None: sources_token = navigate("/sources.html") login_token = navigate("/login.html") history_token = navigate("/history.html") # phase 50: the new page - assert sources_token == login_token == history_token == token + docedit_token = navigate("/doc-edit.html") # phase 59: the doc edit screen + assert sources_token == login_token == history_token == docedit_token == token def test_shared_page_is_no_cache_and_versioned( diff --git a/tests/e2e/test_configurable_brand.py b/tests/e2e/test_configurable_brand.py index 6dd8eaf..aab4da3 100644 --- a/tests/e2e/test_configurable_brand.py +++ b/tests/e2e/test_configurable_brand.py @@ -144,8 +144,12 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non r = httpx.get(f"{TESTY_URL}/api/config", timeout=5) assert r.status_code == 200 body = r.json() - assert set(body) == {"app_name", "version"} + # Phase 59 (task 05): the third key is the docs-push flag — the + # "Save as doc" gating; both instances run with BOR_DOCS_REPO + # empty, so it is the inert false here. + assert set(body) == {"app_name", "version", "docs_repo_configured"} assert body["app_name"] == TESTY_NAME + assert body["docs_repo_configured"] is False # The shared conftest instance keeps the default (the other # suites' title/label contract rides on it). diff --git a/tests/e2e/test_response_to_docs.py b/tests/e2e/test_response_to_docs.py new file mode 100644 index 0000000..3bb6c89 --- /dev/null +++ b/tests/e2e/test_response_to_docs.py @@ -0,0 +1,602 @@ +"""Phase 59 story E2E (Playwright): the save → edit → push loop, with +the BARE REPO as source of truth. + +Story: n/a (TODO-derived — "Convert response to documentation that gets +committed back to a repo specified in .env … allows you to modify the +new documentation before [pushing] to the specified repo"). +Run in isolation (DB must be up: ``podman compose up -d db``; ``git`` +on PATH — the suite skips without it): + + uv run pytest tests/e2e/test_response_to_docs.py -v --no-cov + +The loop under test: a completed brain bubble carries a bottom-right +"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it +opens ``/doc-edit.html?draft=`` prefilled (auto-title from the +last question, path ``docs/.md``, body = the answer's MARKDOWN +SOURCE — never the rendered HTML) → Push commits + pushes to the +``.env``-configured branch of the ``.env``-configured repo. Every +success assertion reads the **bare repo itself** (``git show +:``, ``git rev-list``, ``git rev-parse``) — the UI text +is only the entry point (D3: no PR is ever created or attempted — the +flow ends at the push to the branch). + +App boots (the conftest pattern, module-scoped — as in +``test_git_sources_admin.py``): + +* the module app boots with ``BOR_DOCS_REPO=/docs.git`` (a local + BARE repo seeded with one commit on ``main``), ``BOR_DOCS_BRANCH= + bor-docs``, ``BOR_DOCS_BASE_BRANCH=main``, ``BOR_DOCS_WORK_DIR= + /docs-work``; +* ``test_unconfigured_hides_button`` boots a SECOND app (separate + fixture, ``APP_PORT + 1``) with NO docs env — the inert default: + no button for anyone, draft creation still allowed (drafts are + repo-independent), push 409s naming ``BOR_DOCS_REPO``. + +The mock LLM keeps every answer byte-deterministic: the suite replays +the same question through ``POST /api/chat`` (raw SSE, the +``test_chat_rag.py`` pattern) to recover the exact markdown source the +draft must carry — so "body == the answer's markdown source" is an +exact-byte assertion, not a contains check. + +Test → story mapping (Playwright Mapping Rule): +1. ``test_save_edit_push`` +2. ``test_second_push_fast_forwards`` +3. ``test_guest_has_no_button`` +4. ``test_unconfigured_hides_button`` +""" +from __future__ import annotations + +import asyncio +import json +import os +import re +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from urllib.parse import parse_qs, urlsplit + +import httpx +import pytest +from playwright.sync_api import Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient +from e2e.auth_helpers import login +from e2e.conftest import ( + ADMIN_PASSWORD, + APP_PORT, + SESSION_SECRET, + USE_REAL_LLM, + _wait_http, +) + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" + +APP_URL = f"http://127.0.0.1:{APP_PORT}" +#: The unconfigured app's port (task 07: a second app boot WITHOUT +#: ``BOR_DOCS_REPO`` — a separate fixture on the next port, so it can +#: run alongside the module app). +UNCONF_URL = f"http://127.0.0.1:{APP_PORT + 1}" + +BRANCH = "bor-docs" +BASE_BRANCH = "main" + +#: On-topic fixture questions (the house phrasing — proven HIGH gate in +#: test_chat_rag.py / test_pinned_composer.py, so every turn renders a +#: grounded answer with the deterministic marker, never a deflection). +QUESTION_1 = "How is my Kubernetes cluster set up?" +QUESTION_2 = "What is in the new-service deployment?" +MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" + +#: The distinctive line test 1 appends to the body before pushing — +#: ASCII on purpose (git's output must match byte-for-byte), and a +#: module constant so test 2 can reconstruct file 1's expected content +#: (deterministic: the mock answer + this exact suffix). +E2E_MARKER = "E2E-DOCS-MARKER (appended by the response-to-docs story suite)" + +#: The edit screen's URL shape (task 05 navigates with the uuid4 token). +DRAFT_URL_RE = re.compile(r"/doc-edit\.html\?draft=[0-9a-f-]{36}") +#: The success line (task 06): `Pushed to — commit .` +SUCCESS_SHA_RE = re.compile(r"commit ([0-9a-f]{7})\.$") + + +def _git_available() -> bool: + try: + return subprocess.run( + ["git", "--version"], capture_output=True, timeout=10 + ).returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +pytestmark = pytest.mark.skipif( + not _git_available(), reason="git is not on PATH (the docs push is real git)" +) + + +def _git(args: list[str], cwd: Path | None = None) -> str: + """One git command (the bare repo is the source of truth); fail loud.""" + proc = subprocess.run( + ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=60 + ) + assert proc.returncode == 0, f"git {' '.join(args)} failed: {proc.stderr}" + return proc.stdout + + +def doc_slug(title: str) -> str: + """The app.js slug rule (phase 59 locked assumption), ported: + lowercase, runs of non-alphanumerics → ``-``, trimmed, ≤60 chars, + empty → ``note`` (the trailing trim survives a mid-dash 60-cut).""" + slug = ( + re.sub(r"[^a-z0-9]+", "-", title.lower()) + .strip("-")[:60] + .rstrip("-") + ) + return slug or "note" + + +def _admin_cookies(page: Page) -> dict[str, str]: + """The signed session cookies the browser holds after a form login + — used to call the admin API with plain httpx (the + ``test_cache_busting.py`` pattern).""" + return { + c["name"]: c["value"] + for c in page.context.cookies() + if "name" in c and "value" in c + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def docs_repo(tmp_path_factory: pytest.TempPathFactory) -> SimpleNamespace: + """The local BARE docs repo (the .env remote, D3-generic): one + seed commit (``README.md``) pushed as ``main``. ``work`` is where + the app's ``BOR_DOCS_WORK_DIR`` checkout lands (it persists for the + whole module — the second push exercises the existing-checkout + path).""" + base = tmp_path_factory.mktemp("docs-git") + bare = base / "docs.git" + _git(["init", "--bare", str(bare)]) + seed = base / "seed" + _git(["init", "-b", "main", str(seed)]) + (seed / "README.md").write_text("# e2e docs repo\n", encoding="utf-8") + _git(["add", "--", "README.md"], cwd=seed) + # -c identity + no GPG signing: the machine's global git config + # (gpgsign=true here) must not leak into the fixture. + _git( + [ + "-c", "user.name=E2E Seeder", + "-c", "user.email=e2e@local", + "-c", "commit.gpgsign=false", + "commit", "-m", "seed: README", + ], + cwd=seed, + ) + _git(["remote", "add", "origin", str(bare)], cwd=seed) + _git(["push", "origin", "main"], cwd=seed) + return SimpleNamespace(bare=bare, work=base / "docs-work") + + +def _spawn_app(port: int, mock_port: int, docs_env: dict[str, str] | None) -> subprocess.Popen: + """One uvicorn boot (the conftest app_server env shape); ``None`` + docs_env = NO docs variables at all (the unconfigured app).""" + env = dict(os.environ) + env.pop("DEBUGPY", None) + env["BOR_ENVIRONMENT"] = "e2e" + env["BOR_STATIC_DIR"] = str(REPO / "frontend") + env["BOR_LLM_BASE_URL"] = ( + "https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_port}/v1" + ) + # Mock-calibrated threshold (conftest pattern): the fixture questions + # gate HIGH, so every turn is a grounded answer with the marker. + env["BOR_RELEVANCE_THRESHOLD"] = "0.30" + env.setdefault( + "BOR_DATABASE_URL", + "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", + ) + # Phase 16: admin auth must be set or create_app() refuses to boot. + env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD + env["BOR_SESSION_SECRET"] = SESSION_SECRET + if docs_env is None: + for var in ( + "BOR_DOCS_REPO", + "BOR_DOCS_BRANCH", + "BOR_DOCS_BASE_BRANCH", + "BOR_DOCS_WORK_DIR", + ): + env.pop(var, None) + else: + env.update(docs_env) + return subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"], + cwd=REPO, + env=env, + ) + + +def _stop(proc: subprocess.Popen) -> None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="module") +def app_server(mock_llm: int, docs_repo: SimpleNamespace) -> Iterator[str]: + """The configured app under test (module scope — shadows the + conftest session app; an isolated run never starts two).""" + proc = _spawn_app( + APP_PORT, + mock_llm, + { + "BOR_DOCS_REPO": str(docs_repo.bare), + "BOR_DOCS_BRANCH": BRANCH, + "BOR_DOCS_BASE_BRANCH": BASE_BRANCH, + "BOR_DOCS_WORK_DIR": str(docs_repo.work), + }, + ) + try: + _wait_http(f"{APP_URL}/api/health") + yield APP_URL + finally: + _stop(proc) + + +@pytest.fixture(scope="module") +def app_url(app_server: str) -> str: + return app_server + + +@pytest.fixture() +def unconfigured_app(mock_llm: int) -> Iterator[str]: + """The SECOND app boot (task 07): NO ``BOR_DOCS_REPO`` — the inert + default the suite must see as absent-for-everyone + 409 push.""" + proc = _spawn_app(APP_PORT + 1, mock_llm, None) + try: + _wait_http(f"{UNCONF_URL}/api/health") + yield UNCONF_URL + finally: + _stop(proc) + + +# --------------------------------------------------------------------------- +# KB + table hygiene (the E2E isolation pattern — this suite owns the +# KB tables and doc_drafts; both are reset around every test) +# --------------------------------------------------------------------------- + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = { + "_env_file": None, + "llm_base_url": f"http://127.0.0.1:{mock_port}/v1", + } + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread (the Playwright sync API keeps + an asyncio loop on the test thread — the test_chat_rag.py helper).""" + import threading + + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = threading.Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int, seed: bool) -> None: + with SessionLocal() as db: + db.execute(text("TRUNCATE chunks, documents, query_log, doc_drafts")) + db.commit() + if seed: + summary = _run_in_thread(_import_fixtures(mock_port)) + assert summary.added == 13 # the A9 fixture set (test_chat_rag.py) + + +@pytest.fixture(autouse=True) +def _kb_and_clean_drafts(mock_llm: int, db_ready: None) -> Iterator[None]: + """Fresh KB (the deterministic mock embeddings — the grounded + questions gate HIGH) + an empty ``doc_drafts`` table per test.""" + _reset_db(mock_llm, seed=True) + yield + _reset_db(mock_llm, seed=False) + + +# --------------------------------------------------------------------------- +# Story helpers +# --------------------------------------------------------------------------- + + +def _stream_chat_answer(app_url: str, message: str) -> str: + """Replay one turn through the raw SSE endpoint (the + ``test_chat_rag.py`` transport pattern) and return the EXACT answer + text — the markdown source the UI accumulates into ``m.text``, + byte-identical for the deterministic mock (same KB, same question).""" + frames: list[dict[str, Any]] = [] + with httpx.stream( + "POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0 + ) as r: + assert r.status_code == 200 + buf = "" + for part in r.iter_text(): + buf += part + while "\n\n" in buf: + frame, buf = buf.split("\n\n", 1) + if frame.strip().startswith("data:"): + frames.append( + json.loads(frame.strip().removeprefix("data:").strip()) + ) + deltas = [f for f in frames if f.get("type") == "delta"] + assert deltas, "the SSE stream must deliver deltas" + return "".join(d["text"] for d in deltas) + + +def _ask(page: Page, app_url: str, question: str) -> None: + """One grounded turn to its DONE state (marker in the bubble + the + send button re-enabled — the meta-row buttons land on done).""" + page.fill("#message-input", question) + page.click("#send-btn") + bubble = page.locator(".msg.brain .bubble:not(.typing)").first + expect(bubble).to_contain_text(question, timeout=30_000) + expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000) + expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) + expect(page.locator("#send-label")).to_have_text("Send") + + +def _login_admin(page: Page, app_url: str) -> None: + """Real form login landing on the chat (admin settled).""" + login(page, app_url, next="/") + expect(page).to_have_url(app_url + "/", timeout=30_000) + expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000) + + +def _open_edit_screen(page: Page) -> str: + """Click the save action, wait for the navigation, return the draft + token from the URL (the uuid4 credential).""" + page.click(".save-as-doc-btn") + page.wait_for_url(DRAFT_URL_RE, timeout=30_000) + token = parse_qs(urlsplit(page.url).query).get("draft", [""])[0] + assert re.fullmatch(r"[0-9a-f-]{36}", token), f"no draft token in {page.url}" + expect(page.locator("#doc-edit-gate")).to_be_hidden(timeout=30_000) + expect(page.locator("#doc-edit-content")).to_be_visible(timeout=30_000) + return token + + +def _push_and_read_sha(page: Page) -> tuple[str, str]: + """Submit the edit screen's push; wait for the success line and + return (branch, sha7) exactly as the live region reported them.""" + page.click("#push-doc-btn") + status = page.locator("#push-status") + expect(status).to_contain_text(f"Pushed to {BRANCH}", timeout=60_000) + line = status.inner_text().strip() + m = SUCCESS_SHA_RE.search(line) + assert m, f"the success line carries no commit sha: {line!r}" + return BRANCH, m.group(1) + + +# --------------------------------------------------------------------------- +# 1. The whole loop: save → edit → push → the bare repo agrees +# --------------------------------------------------------------------------- + + +def test_save_edit_push( + page: Page, + app_url: str, + mock_llm: int, + db_ready: None, + docs_repo: SimpleNamespace, +) -> None: + page.set_default_timeout(30_000) + _login_admin(page, app_url) + _ask(page, app_url, QUESTION_1) + + # The "Save as doc" action is on the completed brain bubble… + btn = page.locator(".msg.brain .save-as-doc-btn") + expect(btn).to_have_count(1) + expect(btn).to_contain_text("Save as doc") + # …bottom-right: its left edge sits past the bubble's midline + # (margin-inline-start: auto in the meta row). + msg_box = page.locator(".msg.brain").bounding_box() + btn_box = btn.bounding_box() + assert msg_box is not None and btn_box is not None + midline = msg_box["x"] + msg_box["width"] / 2 + assert btn_box["x"] > midline, ( + f"save button x={btn_box['x']:.0f} is not past the bubble midline " + f"{midline:.0f} — it must sit bottom-right" + ) + + # Click → /doc-edit.html?draft=, prefilled. + _open_edit_screen(page) + expect(page.locator("#draft-title")).to_have_value(QUESTION_1) # auto-title + expect(page.locator("#draft-path")).to_have_value( + f"docs/{doc_slug(QUESTION_1)}.md" + ) + # Body == the rendered answer's MARKDOWN SOURCE: the SSE replay + # recovers the exact bytes the UI accumulated (the mock is + # byte-deterministic on the same KB + question) — and they are + # plain markdown, not rendered HTML. + raw = _stream_chat_answer(app_url, QUESTION_1) + assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw + assert "<" not in raw and ">" not in raw, "the draft body must be markdown, not HTML" + expect(page.locator("#draft-body")).to_have_value(raw) + + # Modify the doc (the story's "modify before [pushing]"): a + # distinctive marker line the bare repo must show after the push. + edited = f"{raw}\n\n{E2E_MARKER}" + page.fill("#draft-body", edited) + + # Push → the live region reports the branch + a 7-char commit sha… + branch, sha7 = _push_and_read_sha(page) + assert branch == BRANCH + + # …and the BARE REPO agrees (the source of truth — not the UI): + # the file on the branch is exactly the edited body… + path = f"docs/{doc_slug(QUESTION_1)}.md" + shown = _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path}"]) + assert shown == edited + # …and the branch tip's first 7 chars are the sha the UI reported. + tip = _git(["-C", str(docs_repo.bare), "rev-parse", BRANCH]).strip() + assert tip.startswith(sha7), f"UI sha {sha7} != bare repo tip {tip}" + # First push: the branch exists and is exactly one commit beyond + # main (created by the push — the remote had no bor-docs before). + assert ( + _git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"]) + .strip() + == "1" + ) + + +# --------------------------------------------------------------------------- +# 2. A second save fast-forwards: two commits, file 1 untouched +# --------------------------------------------------------------------------- + + +def test_second_push_fast_forwards( + page: Page, + app_url: str, + mock_llm: int, + db_ready: None, + docs_repo: SimpleNamespace, +) -> None: + page.set_default_timeout(30_000) + _login_admin(page, app_url) + _ask(page, app_url, QUESTION_2) + + # Save the second answer (different question → different slug)… + expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1) + _open_edit_screen(page) + expect(page.locator("#draft-title")).to_have_value(QUESTION_2) + expect(page.locator("#draft-path")).to_have_value( + f"docs/{doc_slug(QUESTION_2)}.md" + ) + raw2 = _stream_chat_answer(app_url, QUESTION_2) + expect(page.locator("#draft-body")).to_have_value(raw2) + + # …and push WITHOUT editing — a new commit on the same branch. + _push_and_read_sha(page) + + # The bare repo: exactly two commits beyond main (fast-forward, + # never a force-push or a reset)… + assert ( + _git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"]) + .strip() + == "2" + ) + # …file 2 landed with its unedited body… + path2 = f"docs/{doc_slug(QUESTION_2)}.md" + assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"]) == raw2 + # …and file 1 from test 1 is still at its path, byte-for-byte + # (deterministic reconstruction: the mock answer + the marker line). + path1 = f"docs/{doc_slug(QUESTION_1)}.md" + expected_first = f"{_stream_chat_answer(app_url, QUESTION_1)}\n\n{E2E_MARKER}" + assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path1}"]) == expected_first + + +# --------------------------------------------------------------------------- +# 3. Guest: no button, 403 on the draft API, the edit screen gates +# --------------------------------------------------------------------------- + + +def test_guest_has_no_button( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + page.set_default_timeout(30_000) + + # No login (the conftest page fixture is a fresh context). Track + # every /api/doc-drafts request the PAGES make — the guest flow + # must never reach the admin API. + drafts_calls: list[str] = [] + page.on( + "request", + lambda r: drafts_calls.append(r.url) if "/api/doc-drafts" in r.url else None, + ) + + page.goto(app_url) + expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000) + _ask(page, app_url, QUESTION_1) # the grounded answer streams for guests too + + # The "Save as doc" action is admin-only: ABSENT (not hidden) on + # the completed bubble, whatever the docs config says. + expect(page.locator(".save-as-doc-btn")).to_have_count(0) + + # The draft API 403s anonymous callers (httpx, no cookie at all). + r = httpx.post( + f"{app_url}/api/doc-drafts", + json={"title": "guest", "path": "docs/guest.md", "body": "nope"}, + timeout=10, + ) + assert r.status_code == 403 + + # The edit screen renders the admin gate with NO draft data in the + # DOM, and the page itself made zero draft API calls. + page.goto(app_url + "/doc-edit.html") + expect(page.locator("#doc-edit-gate")).to_be_visible(timeout=30_000) + expect(page.locator("#doc-edit-content")).to_be_hidden() + assert page.input_value("#draft-title") == "" + assert page.input_value("#draft-path") == "" + assert page.input_value("#draft-body") == "" + assert drafts_calls == [], f"guest pages called the draft API: {drafts_calls}" + + +# --------------------------------------------------------------------------- +# 4. Unconfigured (BOR_DOCS_REPO empty): inert for everyone, 409 push +# --------------------------------------------------------------------------- + + +def test_unconfigured_hides_button( + page: Page, unconfigured_app: str, mock_llm: int, db_ready: None +) -> None: + page.set_default_timeout(30_000) + _login_admin(page, unconfigured_app) + _ask(page, unconfigured_app, QUESTION_1) + + # docs_repo_configured false → the button is hidden for EVERYONE, + # admin included (the optional-feature pattern — inert by default). + expect(page.locator(".save-as-doc-btn")).to_have_count(0) + + # Drafts are repo-independent: an admin can still create one… + cookies = _admin_cookies(page) + r = httpx.post( + f"{unconfigured_app}/api/doc-drafts", + json={ + "title": "Unconfigured draft", + "path": "docs/unconfigured.md", + "body": "A draft while no docs repo is configured.", + }, + timeout=10, + cookies=cookies, + ) + assert r.status_code == 201, r.text + token = r.json()["token"] + + # …but pushing 409s, naming the missing variable (D3: fail loud, + # inert by default). + r = httpx.post( + f"{unconfigured_app}/api/doc-drafts/{token}/push", timeout=10, cookies=cookies + ) + assert r.status_code == 409 + assert "BOR_DOCS_REPO" in r.json()["detail"] diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 1a5ee28..7526a29 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -18,13 +18,16 @@ def test_health_reports_ok(client) -> None: def test_config_returns_default_app_metadata(client) -> None: - """GET /api/config is public (anonymous) and returns exactly two keys.""" + """GET /api/config is public (anonymous) and returns exactly three + keys — the phase-39 app metadata + the phase-59 docs flag (inert + false while BOR_DOCS_REPO is empty — the "Save as doc" gating).""" r = client.get("/api/config") assert r.status_code == 200 body = r.json() - assert set(body) == {"app_name", "version"} + assert set(body) == {"app_name", "version", "docs_repo_configured"} assert body["app_name"] == "Brain of Reese" assert body["version"] == get_settings().app_version + assert body["docs_repo_configured"] is False def test_config_follows_overridden_app_name(client) -> None: @@ -39,9 +42,32 @@ def test_config_follows_overridden_app_name(client) -> None: r = client.get("/api/config") assert r.status_code == 200 body = r.json() - assert set(body) == {"app_name", "version"} + assert set(body) == {"app_name", "version", "docs_repo_configured"} assert body["app_name"] == "Brain of Testy" assert body["version"] == "0.1.0" + assert body["docs_repo_configured"] is False + finally: + fastapi_app.dependency_overrides.clear() + + +def test_config_docs_flag_tracks_settings(client) -> None: + """Phase 59 (task 05): ``docs_repo_configured`` mirrors + ``settings.docs_configured`` — a real bool (never a truthy string) + that flips true the moment BOR_DOCS_REPO is non-empty: that flag is + the entire frontend gating of the "Save as doc" button.""" + from app.config import Settings + from app.main import app as fastapi_app + + fastapi_app.dependency_overrides[get_settings] = lambda: Settings( + app_name="Brain of Testy", + docs_repo="/srv/docs-repo", + ) + try: + r = client.get("/api/config") + assert r.status_code == 200 + body = r.json() + assert isinstance(body["docs_repo_configured"], bool) + assert body["docs_repo_configured"] is True finally: fastapi_app.dependency_overrides.clear() diff --git a/tests/integration/test_doc_drafts_api.py b/tests/integration/test_doc_drafts_api.py new file mode 100644 index 0000000..ded1243 --- /dev/null +++ b/tests/integration/test_doc_drafts_api.py @@ -0,0 +1,536 @@ +"""Integration: doc-drafts API (phase 59, task 02) — create / get / update. + +The draft lifecycle the edit screen runs on: create (from a response), +fetch by token, update (modify before push) — all admin-only +(router-wide ``require_admin``), all path-guard-railed (no path that can +escape the repo root). + +Real Postgres (``podman compose up -d db``); no LLM involved — drafts +are plain rows, so the suite is deterministic without a fake. + +Requires: podman compose up -d db +""" +from __future__ import annotations + +import subprocess +import uuid +from collections.abc import Iterator +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select, text + +from app.config import Settings, get_settings +from app.main import app as fastapi_app +from app.models import DocDraft + +TITLE = "How do I deploy a new service?" +PATH = "docs/note.md" +BODY = "# Answer\n\nSome **markdown** body." +BASE_BRANCH = "main" +DOCS_BRANCH = "bor-docs" + + +def _settings(**kwargs: Any) -> Settings: + """Build Settings without reading a .env file (deterministic tests).""" + kwargs.setdefault("_env_file", None) + return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime) + + +def _git_available() -> bool: + try: + proc = subprocess.run(["git", "--version"], capture_output=True, check=False) + return proc.returncode == 0 + except (FileNotFoundError, OSError): + return False + + +#: The push tests drive a real local git repo — skipped (not failed) on a +#: machine without the git CLI (the task-03 unit-suite guard). +GIT = _git_available() + + +def _git(cwd: Path, *argv: str) -> str: + """Run git for the tests themselves (fixture setup + assertions).""" + proc = subprocess.run(["git", *argv], cwd=cwd, capture_output=True, text=True, check=False) + assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}" + return proc.stdout + + +@pytest.fixture(autouse=True) +def clean_drafts(db) -> Iterator[None]: + """doc_drafts is global state: reset around every test.""" + db.execute(text("TRUNCATE doc_drafts")) + db.commit() + yield + db.execute(text("TRUNCATE doc_drafts")) + db.commit() + + +def _create(admin_client: TestClient, **overrides) -> dict: + """POST a well-formed draft (201) and return the response body.""" + payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides} + r = admin_client.post("/api/doc-drafts", json=payload) + assert r.status_code == 201, r.text + return r.json() + + +def _backdate_updated_at(db, token: uuid.UUID) -> None: + """Push the row's ``updated_at`` one hour back (raw SQL — a hand- + written UPDATE does not trigger the column's onupdate default), so + a subsequent API write's bump is observable deterministically.""" + db.execute( + text("UPDATE doc_drafts SET updated_at = now() - interval '1 hour' WHERE token = :t"), + {"t": token}, + ) + db.commit() + + +# ---------- create (POST) ---------- + + +def test_create_returns_201_with_all_fields_and_draft_status( + admin_client: TestClient, db +) -> None: + r = admin_client.post("/api/doc-drafts", json={"title": TITLE, "path": PATH, "body": BODY}) + + assert r.status_code == 201 + body = r.json() + assert body["title"] == TITLE + assert body["path"] == PATH + assert body["body"] == BODY + assert body["status"] == "draft" + # The push feedback columns are NULL while still a draft. + assert body["branch"] is None + assert body["commit_sha"] is None + # The token: present, non-NULL, a valid (unguessable) UUID. + assert body["token"] + tok = uuid.UUID(body["token"]) + assert body["created_at"] + assert body["updated_at"] + + # The row is in Postgres under the same token (the URL credential). + row = db.execute(select(DocDraft).where(DocDraft.token == tok)).scalars().one() + assert row.title == TITLE + assert row.path == PATH + assert row.body == BODY + assert row.status == "draft" + + +def test_create_strips_title_body_and_path(admin_client: TestClient) -> None: + body = _create( + admin_client, title=f" {TITLE} ", path=f" {PATH} ", body=f"\n{BODY}\n" + ) + assert body["title"] == TITLE + assert body["path"] == PATH + assert body["body"] == BODY + + +def test_create_rejects_blank_title_body_path(admin_client: TestClient) -> None: + # Whitespace-only values: past pydantic's min_length=1, caught by the + # API's non-empty-after-strip rule (422), nothing stored. + for overrides in ({"title": " "}, {"body": " \t\n "}, {"path": " "}): + payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides} + assert admin_client.post("/api/doc-drafts", json=payload).status_code == 422 + # Truly empty title/body: pydantic 422 (min_length=1). + empty_title = {"title": "", "path": PATH, "body": BODY} + assert admin_client.post("/api/doc-drafts", json=empty_title).status_code == 422 + empty_body = {"title": TITLE, "path": PATH, "body": ""} + assert admin_client.post("/api/doc-drafts", json=empty_body).status_code == 422 + + +# ---------- path guard-rails (shared with the push endpoint) ---------- + + +@pytest.mark.parametrize( + ("bad_path", "rule_in_detail"), + [ + ("/etc/passwd", "absolute"), + ("../x.md", "'..'"), + ("a/b/../c.md", "'..'"), + ("no-suffix", "suffix"), + (" ", "empty"), + ], +) +def test_create_path_guard_rejects_each_rule_with_422( + admin_client: TestClient, bad_path: str, rule_in_detail: str +) -> None: + r = admin_client.post( + "/api/doc-drafts", json={"title": TITLE, "path": bad_path, "body": BODY} + ) + assert r.status_code == 422 + assert rule_in_detail in r.json()["detail"] + + +def test_create_accepts_repo_relative_path_with_suffix(admin_client: TestClient) -> None: + body = _create(admin_client, path="docs/note.md") + assert body["path"] == "docs/note.md" + + +def test_put_path_guard_rejects_traversal(admin_client: TestClient) -> None: + created = _create(admin_client) + for bad in ("/etc/passwd", "../x.md", "a/b/../c.md", "no-suffix"): + r = admin_client.put( + f"/api/doc-drafts/{created['token']}", json={"path": bad} + ) + assert r.status_code == 422, bad + # The row is untouched by the rejected updates. + body = admin_client.get(f"/api/doc-drafts/{created['token']}").json() + assert body["path"] == PATH + + +# ---------- get (by token) ---------- + + +def test_get_round_trips_created_draft(admin_client: TestClient) -> None: + created = _create(admin_client) + + r = admin_client.get(f"/api/doc-drafts/{created['token']}") + + assert r.status_code == 200 + assert r.json() == created + + +def test_get_unknown_token_returns_404(admin_client: TestClient) -> None: + r = admin_client.get(f"/api/doc-drafts/{uuid.uuid4()}") + assert r.status_code == 404 + assert r.json() == {"detail": "draft not found"} + + +def test_get_malformed_token_returns_422(admin_client: TestClient) -> None: + assert admin_client.get("/api/doc-drafts/not-a-uuid").status_code == 422 + + +# ---------- update (PUT) ---------- + + +def test_put_partial_body_only_keeps_title_and_path(admin_client: TestClient, db) -> None: + created = _create(admin_client) + token = uuid.UUID(created["token"]) + _backdate_updated_at(db, token) + + r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "# v2\n\nEdited."}) + + assert r.status_code == 200 + body = r.json() + assert body["title"] == TITLE # absent → unchanged + assert body["path"] == PATH # absent → unchanged + assert body["body"] == "# v2\n\nEdited." + assert body["status"] == "draft" + assert body["created_at"] == created["created_at"] # editing does not redate creation + # updated_at was bumped past the backdated value. + assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat( + created["updated_at"] + ) + + +def test_put_replaces_all_fields_when_supplied(admin_client: TestClient) -> None: + created = _create(admin_client) + + r = admin_client.put( + f"/api/doc-drafts/{created['token']}", + json={"title": "New title", "path": "docs/other.md", "body": "New body."}, + ) + + assert r.status_code == 200 + body = r.json() + assert body["title"] == "New title" + assert body["path"] == "docs/other.md" + assert body["body"] == "New body." + assert body["status"] == "draft" + + +def test_put_noop_still_bumps_updated_at(admin_client: TestClient, db) -> None: + """A PUT whose supplied values are all identical (or empty body) + changes no stored value — the ORM flushes nothing — yet the contract + is that a PUT bumps ``updated_at`` (the raw-UPDATE fallback).""" + created = _create(admin_client) + token = uuid.UUID(created["token"]) + _backdate_updated_at(db, token) + + r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": BODY}) # identical + + assert r.status_code == 200 + assert r.json()["body"] == BODY + assert datetime.fromisoformat(r.json()["updated_at"]) > datetime.fromisoformat( + created["updated_at"] + ) + + # And an empty partial body (no fields at all) does the same. + _backdate_updated_at(db, token) + r2 = admin_client.put(f"/api/doc-drafts/{token}", json={}) + assert r2.status_code == 200 + assert datetime.fromisoformat(r2.json()["updated_at"]) > datetime.fromisoformat( + created["updated_at"] + ) + + +def test_put_resets_pushed_draft_to_draft(admin_client: TestClient, db) -> None: + created = _create(admin_client) + token = uuid.UUID(created["token"]) + + # Mark the draft pushed directly in the DB (the push endpoint's job + # lands in task 04 — here we pin the edit-side consequence). + row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().one() + row.status = "pushed" + row.branch = "bor-docs" + row.commit_sha = "a" * 40 + db.commit() + + r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "Edited after push."}) + + assert r.status_code == 200 + body = r.json() + assert body["status"] == "draft" # the stored sha no longer describes the body + assert body["body"] == "Edited after push." + assert body["title"] == TITLE # absent → unchanged + assert body["path"] == PATH # absent → unchanged + # The last push stays visible until the next push overwrites it. + assert body["branch"] == "bor-docs" + assert body["commit_sha"] == "a" * 40 + + +def test_put_unknown_token_returns_404(admin_client: TestClient) -> None: + r = admin_client.put(f"/api/doc-drafts/{uuid.uuid4()}", json={"body": "x"}) + assert r.status_code == 404 + assert r.json() == {"detail": "draft not found"} + + +def test_put_malformed_token_returns_422(admin_client: TestClient) -> None: + assert admin_client.put("/api/doc-drafts/not-a-uuid", json={"body": "x"}).status_code == 422 + + +def test_put_rejects_blank_fields_and_leaves_row_unchanged(admin_client: TestClient) -> None: + created = _create(admin_client) + for bad in ({"title": " "}, {"body": " \t "}, {"path": " "}): + assert admin_client.put(f"/api/doc-drafts/{created['token']}", json=bad).status_code == 422 + assert admin_client.get(f"/api/doc-drafts/{created['token']}").json() == created + + +# ---------- auth: anonymous gets 403 on every route ---------- + + +def test_anonymous_gets_403_on_all_routes(admin_client: TestClient, db) -> None: + created = _create(admin_client, body="admin-created") + anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie) + + r = anon.post("/api/doc-drafts", json={"title": "x", "path": "docs/x.md", "body": "y"}) + assert r.status_code == 403 + assert r.json() == {"detail": "admin only"} + assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403 + assert anon.put(f"/api/doc-drafts/{created['token']}", json={"body": "nope"}).status_code == 403 + + # The anonymous attempts changed nothing: exactly the admin's draft + # exists, untouched. + rows = db.execute(select(DocDraft)).scalars().all() + assert len(rows) == 1 + assert rows[0].body == "admin-created" + + +# ---------- push (POST /{token}/push — task 04) ---------- +# +# The push tests run against a **real local bare repo** (the task-03 +# unit pattern) and inject the endpoint's settings via the app's +# ``Depends(get_settings)`` override (the house pattern — +# ``app/api/config.py`` takes ``settings: Settings = Depends( +# get_settings)``). Result assertions read the bare repo's state +# (``git show :``, ``git rev-parse``), not the response +# alone. + + +@pytest.fixture() +def bare_docs_repo(tmp_path: Path) -> Path: + """A bare origin seeded with one commit on ``main`` (``README.md``).""" + if not GIT: + pytest.skip("git is not available on this machine") + bare = tmp_path / "bare.git" + _git(tmp_path, "init", "--bare", str(bare)) + seed = tmp_path / "seed" + _git(tmp_path, "clone", str(bare), str(seed)) + (seed / "README.md").write_text("# docs\n", encoding="utf-8") + _git(seed, "checkout", "-B", BASE_BRANCH) + _git( + seed, + "-c", "commit.gpgsign=false", + "-c", "user.name=Test", + "-c", "user.email=t@example.com", + "add", "README.md", + ) + _git( + seed, + "-c", "commit.gpgsign=false", + "-c", "user.name=Test", + "-c", "user.email=t@example.com", + "commit", "-m", "seed README", + ) + _git(seed, "push", "origin", BASE_BRANCH) + return bare + + +@pytest.fixture() +def docs_push_settings(bare_docs_repo: Path, tmp_path: Path) -> Iterator[Settings]: + """Settings pointing at the fixture bare repo, injected into the + endpoint's settings dependency; the override is removed after the + test (no leak into other tests' settings).""" + settings = _settings( + docs_repo=str(bare_docs_repo), + docs_branch=DOCS_BRANCH, + docs_base_branch=BASE_BRANCH, + docs_work_dir=str(tmp_path / "docs-workdir"), + ) + fastapi_app.dependency_overrides[get_settings] = lambda: settings + try: + yield settings + finally: + fastapi_app.dependency_overrides.pop(get_settings, None) + + +def test_push_success_commits_and_records_branch_and_sha( + admin_client: TestClient, db, docs_push_settings: Settings, bare_docs_repo: Path +) -> None: + created = _create(admin_client) + _backdate_updated_at(db, uuid.UUID(created["token"])) + + r = admin_client.post(f"/api/doc-drafts/{created['token']}/push") + + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "pushed" + assert body["branch"] == DOCS_BRANCH + sha = body["commit_sha"] + assert len(sha) == 40 + # The source of truth is the bare repo's state — not the response: + # the file landed on the branch at exactly the returned sha, with + # the draft's body, under the fixed per-invocation identity. + assert _git(bare_docs_repo, "rev-parse", DOCS_BRANCH).strip() == sha + assert _git(bare_docs_repo, "show", f"{DOCS_BRANCH}:{PATH}") == BODY + ident = _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%an <%ae>").strip() + assert ident == "Brain of Reese " + assert _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%s").strip() == ( + f"docs: {TITLE}" + ) + + # The DB row records the outcome (status + branch + sha), and the + # GET endpoint reports it. + row = db.execute( + select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"])) + ).scalars().one() + assert row.status == "pushed" + assert row.branch == DOCS_BRANCH + assert row.commit_sha == sha + got = admin_client.get(f"/api/doc-drafts/{created['token']}").json() + assert got["status"] == "pushed" + assert got["branch"] == DOCS_BRANCH + assert got["commit_sha"] == sha + # The success bumped updated_at (past the backdated value). + assert datetime.fromisoformat(got["updated_at"]) > datetime.fromisoformat( + created["updated_at"] + ) + + +def test_push_unconfigured_returns_409_naming_variable(admin_client: TestClient, db) -> None: + """Default settings (``docs_repo=""``) → 409 naming the variable; + the row stays a draft (D3: inert by default).""" + created = _create(admin_client) + fastapi_app.dependency_overrides[get_settings] = lambda: _settings() # docs_repo="" + try: + r = admin_client.post(f"/api/doc-drafts/{created['token']}/push") + finally: + fastapi_app.dependency_overrides.pop(get_settings, None) + + assert r.status_code == 409 + assert r.json() == {"detail": "docs repo not configured (BOR_DOCS_REPO)"} + body = admin_client.get(f"/api/doc-drafts/{created['token']}").json() + assert body["status"] == "draft" + assert body["branch"] is None + assert body["commit_sha"] is None + + +def test_push_non_repo_dir_returns_502_with_git_stderr( + admin_client: TestClient, db, tmp_path: Path +) -> None: + """A configured repo that is not a git repo → 502 with git's stderr + in the detail (the ``GitSyncError`` → ``detail`` mapping); + the row stays a draft (only a success mutates).""" + plain = tmp_path / "not-a-repo" + plain.mkdir() + (plain / "file.txt").write_text("not a repo\n", encoding="utf-8") + fastapi_app.dependency_overrides[ + get_settings + ] = lambda: _settings( + docs_repo=str(plain), + docs_branch=DOCS_BRANCH, + docs_base_branch=BASE_BRANCH, + docs_work_dir=str(tmp_path / "docs-workdir"), + ) + created = _create(admin_client) + try: + r = admin_client.post(f"/api/doc-drafts/{created['token']}/push") + finally: + fastapi_app.dependency_overrides.pop(get_settings, None) + + assert r.status_code == 502 + detail = r.json()["detail"] + # git's stderr is surfaced (the clone refusal of a non-repo dir). + assert "failed" in detail + assert "fatal: repository" in detail + body = admin_client.get(f"/api/doc-drafts/{created['token']}").json() + assert body["status"] == "draft" + assert body["branch"] is None + assert body["commit_sha"] is None + + +def test_push_unknown_token_returns_404( + admin_client: TestClient, docs_push_settings: Settings +) -> None: + r = admin_client.post(f"/api/doc-drafts/{uuid.uuid4()}/push") + assert r.status_code == 404 + assert r.json() == {"detail": "draft not found"} + + +def test_push_rejects_bad_stored_path_with_422( + admin_client: TestClient, db, docs_push_settings: Settings +) -> None: + """A row whose stored path no longer passes the guard-rails must not + be pushable (422 naming the rule — re-validated on push, task 02 + helper); the row is untouched.""" + bad = DocDraft(token=uuid.uuid4(), title=TITLE, path="../evil.md", body=BODY) + db.add(bad) + db.commit() + db.refresh(bad) + + r = admin_client.post(f"/api/doc-drafts/{bad.token}/push") + + assert r.status_code == 422 + assert "'..'" in r.json()["detail"] + row = db.get(DocDraft, bad.id) + assert row is not None + assert row.status == "draft" + assert row.branch is None + assert row.commit_sha is None + + +def test_push_anonymous_returns_403( + admin_client: TestClient, + db, + docs_push_settings: Settings, + bare_docs_repo: Path, +) -> None: + created = _create(admin_client) + anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie) + + r = anon.post(f"/api/doc-drafts/{created['token']}/push") + + assert r.status_code == 403 + assert r.json() == {"detail": "admin only"} + # The anonymous push attempt changed nothing: no branch on the bare + # repo, the row is still a draft (guest reads 403 too). + assert _git(bare_docs_repo, "branch", "--list", DOCS_BRANCH).strip() == "" + assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403 + row = db.execute( + select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"])) + ).scalars().one() + assert row.status == "draft" diff --git a/tests/integration/test_migration_0011.py b/tests/integration/test_migration_0011.py new file mode 100644 index 0000000..a36e463 --- /dev/null +++ b/tests/integration/test_migration_0011.py @@ -0,0 +1,322 @@ +"""Integration: migration 0011 (doc_drafts) schema contract. + +Drives the **real Alembic engine** against the live dev database +(``podman compose up -d db``), mirroring the house pattern of +``test_migration_0010.py`` (information_schema / pg_indexes assertions +on the state the migration must leave). The tests target revision +``0011`` explicitly so later migrations cannot break them: + +* upgrade 0010 → 0011 → the ``doc_drafts`` table exists with the full + column contract (``id`` UUID PK; ``token`` UUID NOT NULL + the UNIQUE + index ``ix_doc_drafts_token`` — the URL credential; ``title`` / + ``path`` / ``body`` TEXT NOT NULL; ``status`` TEXT NOT NULL default + 'draft'; ``branch`` / ``commit_sha`` TEXT NULL; ``created_at`` / + ``updated_at`` TIMESTAMPTZ NOT NULL default now()); +* inserted rows round-trip: an omitted ``status`` defaults to 'draft' + with NULL ``branch`` / ``commit_sha`` (the pre-push state) and both + timestamps are stamped server-side; explicit push-state values + round-trip verbatim; +* two identical tokens are rejected by the unique index (the token is + a unique handle — the share-token precedent, phase 51); +* downgrade to 0010 → the table and index are gone (A13 — reversible), + the rest of the schema (e.g. ``saved_chats.share_token``) survives; +* upgrade back to 0011 → the table and the unique index are back + (round-trip). + +The ``alembic`` fixture guarantees the DB ends at head even if a test +fails or the process is interrupted. +""" +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from typing import Any + +import pytest +from alembic.config import Config +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from alembic import command +from app.db import db_available + + +@pytest.fixture() +def alembic(db: Session) -> Iterator[Config]: + """Real Alembic config bound to the dev DB (URL from app settings). + + Starts at head (repairs an interrupted earlier run); teardown upgrades + to head no matter what happened, so the dev DB is never left below + head. + """ + if not db_available(): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + cfg = Config() # no alembic.ini file — env.py gets the URL from app config + cfg.set_main_option("script_location", "alembic") + command.upgrade(cfg, "head") + try: + yield cfg + finally: + command.upgrade(cfg, "head") + + +def _version(db: Session) -> str | None: + return db.execute(text("SELECT version_num FROM alembic_version")).scalar() + + +def _table_exists(db: Session, table: str) -> bool: + count: Any = db.execute( + text( + "SELECT count(*) FROM information_schema.tables" + " WHERE table_schema = 'public' AND table_name = :t" + ), + {"t": table}, + ).scalar() + assert count is not None, "information_schema count must be an int" + return int(count) == 1 + + +def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None: + """(data_type, is_nullable, column_default) for one table column.""" + row = db.execute( + text( + "SELECT data_type, is_nullable, column_default" + " FROM information_schema.columns" + " WHERE table_name = :t AND column_name = :c" + ), + {"t": table, "c": column}, + ).fetchone() + return tuple(row) if row is not None else None + + +def _unique_token_index(db: Session) -> int: + """1 iff ``ix_doc_drafts_token`` exists as a UNIQUE index.""" + count: Any = db.execute( + text( + "SELECT count(*) FROM pg_indexes" + " WHERE tablename = 'doc_drafts'" + " AND indexname = 'ix_doc_drafts_token'" + ), + ).scalar() + assert count is not None, "pg_indexes count must be an int" + is_unique: Any = db.execute( + text( + "SELECT indisunique FROM pg_index" + " WHERE indexrelid = (SELECT oid FROM pg_class WHERE relname = 'ix_doc_drafts_token')" + ), + ).scalar() + return int(count) if is_unique else 0 + + +def _insert( + db: Session, + *, + token: uuid.UUID | None = None, + status: str | None = None, + branch: str | None = None, + commit_sha: str | None = None, +) -> uuid.UUID: + """Insert one doc_drafts row. ``status=None`` omits the column + (server-default path); a ``token`` is always supplied — the + migration carries no server default (the ORM/API supplies it).""" + cols = ["id", "token", "title", "path", "body"] + params: dict[str, Any] = { + "t": "Mig 0011", + "p": "docs/mig-0011.md", + "b": "# Phase 59 migration probe\n", + } + if token is not None: + params["tok"] = token + if status is not None: + cols.append("status") + params["s"] = status + if branch is not None: + cols.append("branch") + params["br"] = branch + if commit_sha is not None: + cols.append("commit_sha") + params["sha"] = commit_sha + sql = ( + f"INSERT INTO doc_drafts ({', '.join(cols)}) VALUES (" + "gen_random_uuid(), :tok, :t, :p, :b" + + (", :s" if status is not None else "") + + (", :br" if branch is not None else "") + + (", :sha" if commit_sha is not None else "") + + ") RETURNING id" + ) + draft_id: uuid.UUID = db.execute(text(sql), params).scalar_one() + db.commit() + return draft_id + + +def _delete(db: Session, draft_id: uuid.UUID) -> None: + db.execute(text("DELETE FROM doc_drafts WHERE id = :i"), {"i": draft_id}) + db.commit() + + +def test_upgrade_to_0011_adds_doc_drafts(db: Session, alembic: Config) -> None: + """Upgrade 0010 → 0011: the table + the unique token index exist + with the full column contract; the table is absent at 0010.""" + command.downgrade(alembic, "0010") # start from the pre-0011 state + assert _version(db) == "0010" + assert not _table_exists(db, "doc_drafts"), "doc_drafts must be absent at 0010" + assert _unique_token_index(db) == 0, "the token index must be absent at 0010" + + command.upgrade(alembic, "0011") + assert _version(db) == "0011", "alembic_version must be at 0011" + assert _table_exists(db, "doc_drafts"), "doc_drafts must exist at 0011" + + id_col = _column(db, "doc_drafts", "id") + assert id_col is not None, "doc_drafts.id is missing" + assert id_col[0] == "uuid", "doc_drafts.id must be UUID" + assert id_col[1] == "NO", "doc_drafts.id must be NOT NULL (PK)" + + token = _column(db, "doc_drafts", "token") + assert token is not None, "doc_drafts.token is missing" + assert token[0] == "uuid", "doc_drafts.token must be UUID" + assert token[1] == "NO", "doc_drafts.token must be NOT NULL (no un-drafted state)" + assert _unique_token_index(db) == 1, "the unique token index is missing" + + for name in ("title", "path", "body"): + col = _column(db, "doc_drafts", name) + assert col is not None, f"doc_drafts.{name} is missing" + assert col[0] == "text", f"doc_drafts.{name} must be TEXT" + assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL" + + status = _column(db, "doc_drafts", "status") + assert status is not None, "doc_drafts.status is missing" + assert status[0] == "text", "doc_drafts.status must be TEXT" + assert status[1] == "NO", "doc_drafts.status must be NOT NULL" + assert str(status[2]).startswith("'draft'"), ( + "doc_drafts.status must have server default 'draft'" + ) + + for name in ("branch", "commit_sha"): + col = _column(db, "doc_drafts", name) + assert col is not None, f"doc_drafts.{name} is missing" + assert col[0] == "text", f"doc_drafts.{name} must be TEXT" + assert col[1] == "YES", f"doc_drafts.{name} must be NULL until pushed" + + for name in ("created_at", "updated_at"): + col = _column(db, "doc_drafts", name) + assert col is not None, f"doc_drafts.{name} is missing" + assert col[0] == "timestamp with time zone", ( + f"doc_drafts.{name} must be TIMESTAMPTZ" + ) + assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL" + assert str(col[2]).startswith("now("), ( + f"doc_drafts.{name} must have server default now()" + ) + + +def test_inserted_rows_round_trip_the_pre_push_and_pushed_states( + db: Session, alembic: Config +) -> None: + """At 0011, an omitted status defaults to 'draft' with NULL + branch/commit_sha (the pre-push state) and both timestamps are + stamped server-side; explicit push-state values round-trip + verbatim.""" + command.upgrade(alembic, "head") + draft_token = uuid.uuid4() + draft_id = _insert(db, token=draft_token) + pushed_token = uuid.uuid4() + pushed_id = _insert( + db, + token=pushed_token, + status="pushed", + branch="bor-docs", + commit_sha="a" * 40, + ) + try: + row = db.execute( + text( + "SELECT token, status, branch, commit_sha, created_at, updated_at" + " FROM doc_drafts WHERE id = :i" + ), + {"i": draft_id}, + ).fetchone() + assert row is not None, "the draft row must exist" + assert row[0] == draft_token, "the token must round-trip verbatim" + assert row[1] == "draft", "an omitted status must default to 'draft'" + assert row[2] is None and row[3] is None, ( + "branch/commit_sha must be NULL before the push endpoint runs" + ) + assert row[4] is not None and row[5] is not None, ( + "created_at/updated_at must be stamped server-side" + ) + + pushed = db.execute( + text( + "SELECT status, branch, commit_sha FROM doc_drafts WHERE id = :i" + ), + {"i": pushed_id}, + ).fetchone() + assert pushed is not None, "the pushed row must exist" + assert tuple(pushed) == ("pushed", "bor-docs", "a" * 40), ( + "explicit push-state values must round-trip verbatim" + ) + finally: + _delete(db, draft_id) + _delete(db, pushed_id) + + +def test_unique_index_rejects_duplicate_tokens(db: Session, alembic: Config) -> None: + """Two identical tokens are rejected by the unique index — the + token is the unique URL credential (the share-token precedent, + phase 51); a distinct token still lands.""" + command.upgrade(alembic, "head") + dup_token = uuid.uuid4() + first_id = _insert(db, token=dup_token) + other_id: uuid.UUID | None = None + try: + try: + _insert(db, token=dup_token) + except IntegrityError: + db.rollback() # the aborted transaction must not leak + else: + pytest.fail("a duplicate doc_drafts.token must be rejected") + + # A different token is fine — only the exact duplicate is unique. + other_id = _insert(db, token=uuid.uuid4()) + finally: + _delete(db, first_id) + if other_id is not None: + _delete(db, other_id) + + +def test_downgrade_to_0010_drops_the_table(db: Session, alembic: Config) -> None: + """Downgrade to 0010: the table and the unique index are gone + (A13 — reversible) while the rest of the schema survives.""" + command.downgrade(alembic, "0010") + assert _version(db) == "0010" + assert not _table_exists(db, "doc_drafts"), "doc_drafts must be dropped" + assert _unique_token_index(db) == 0, "the token index must be dropped" + + token_col = _column(db, "saved_chats", "share_token") + assert token_col is not None and token_col[0] == "uuid", ( + "saved_chats.share_token must survive the downgrade" + ) + meta = _column(db, "sources_meta", "version") + assert meta is not None and meta[0] == "integer", ( + "sources_meta.version must survive the downgrade" + ) + + +def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None: + """Downgrade to 0010, then upgrade back to 0011: the table and the + unique index are back.""" + command.downgrade(alembic, "0010") + command.upgrade(alembic, "0011") + assert _version(db) == "0011", "round-trip upgrade must land at 0011" + + assert _table_exists(db, "doc_drafts"), "doc_drafts must be back" + assert _unique_token_index(db) == 1, "the unique token index must be back" + + status = _column(db, "doc_drafts", "status") + assert status is not None and status[1] == "NO", ( + "status must be TEXT NOT NULL after the round-trip" + ) + assert str(status[2]).startswith("'draft'"), ( + "status must default to 'draft' after the round-trip" + ) diff --git a/tests/unit/test_caching.py b/tests/unit/test_caching.py index f26f3ad..da5d067 100644 --- a/tests/unit/test_caching.py +++ b/tests/unit/test_caching.py @@ -212,6 +212,7 @@ def test_html_pages_include_history() -> None: "/git-sources.html", "/history.html", "/shared.html", # phase 51: the shared page's static path + "/doc-edit.html", # phase 59: the doc edit screen (task 06) ): assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 44a3c4a..d20b7ef 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -279,3 +279,121 @@ def test_effective_api_key_fallback(monkeypatch) -> None: monkeypatch.setenv("AIPI_KEY", "sk-from-env") s2 = _settings() assert s2.effective_api_key == "sk-from-env" + + +# --- Docs push (phase 59) --- + + +def test_docs_push_defaults_are_inert() -> None: + """Phase 59, D3: no docs repo by default — the feature is + inert-by-default (button hidden, push endpoint 409s — the + optional-feature pattern of the git-sources env fallback), and the + branch/base defaults + raw work-dir string are in place.""" + s = _settings() + assert s.docs_repo == "" + assert s.docs_configured is False + assert s.docs_branch == "bor-docs" + assert s.docs_base_branch == "main" + # Raw string on purpose — Path.expanduser() is applied by the push + # service, not the setting (the sources_dir/upload_dir convention). + assert s.docs_work_dir == "~/bor-docs" + + +def test_docs_repo_set_is_configured(monkeypatch: pytest.MonkeyPatch) -> None: + """A non-empty ``BOR_DOCS_REPO`` turns the feature on — a URL or a + local path (D3: generic remote, no scheme parsing here).""" + for repo in ("/path/to/docs-repo", "https://git.example.com/docs.git"): + monkeypatch.setenv("BOR_DOCS_REPO", repo) + s = _settings() + assert s.docs_configured is True + assert s.docs_repo == repo + # Whitespace-only behaves like empty: still inert. + monkeypatch.setenv("BOR_DOCS_REPO", " ") + assert _settings().docs_configured is False + + +def test_docs_branch_env_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BOR_DOCS_BRANCH", raising=False) + monkeypatch.delenv("BOR_DOCS_BASE_BRANCH", raising=False) + assert _settings().docs_branch == "bor-docs" + assert _settings().docs_base_branch == "main" + monkeypatch.setenv("BOR_DOCS_BRANCH", "docs-pr") + monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "master") + s = _settings() + assert s.docs_branch == "docs-pr" + assert s.docs_base_branch == "master" + + +def test_docs_work_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BOR_DOCS_WORK_DIR", "/data/bor/docs") + s = _settings() + assert s.docs_work_dir == "/data/bor/docs" + + +def test_docs_branch_whitespace_fails_loudly_when_repo_set( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A whitespace-bearing branch would corrupt a ``git checkout`` + argument — fail loud at startup, naming the field (the + ``agent_max_rounds`` pattern).""" + monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo") + monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs") + with pytest.raises(ValidationError, match="docs_branch"): + _settings() + + +def test_docs_branch_dotdot_fails_loudly_when_repo_set( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``..`` is a path-traversal token, never part of a branch name. + A blank branch is rejected too (empty while a repo is set).""" + monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo") + monkeypatch.setenv("BOR_DOCS_BRANCH", "a..b") + with pytest.raises(ValidationError, match="docs_branch"): + _settings() + monkeypatch.setenv("BOR_DOCS_BRANCH", " ") + with pytest.raises(ValidationError, match="docs_branch"): + _settings() + + +def test_docs_base_branch_invalid_fails_loudly_naming_field( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The base branch gets the same token shape check — the error + names ``docs_base_branch``, not the sibling field.""" + monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo") + monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "bad branch") + with pytest.raises(ValidationError, match="docs_base_branch"): + _settings() + monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "a..b") + with pytest.raises(ValidationError, match="docs_base_branch"): + _settings() + + +def test_docs_branchs_valid_when_repo_set(monkeypatch: pytest.MonkeyPatch) -> None: + """Repo set + well-formed branch tokens boot cleanly and the + feature is configured (dash/dot/slash branch names are legal git + refs and stay accepted).""" + monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo") + s = _settings() # defaults bor-docs / main + assert s.docs_configured is True + monkeypatch.setenv("BOR_DOCS_BRANCH", "feature/docs-update") + monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "develop") + s2 = _settings() + assert s2.docs_configured is True + assert s2.docs_branch == "feature/docs-update" + assert s2.docs_base_branch == "develop" + + +def test_docs_branchs_garbage_ignored_when_repo_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """All-or-nothing: while the repo is empty the feature is inert, so + the (ignored) branch values must NOT block startup — only a + configured repo makes the shape check apply.""" + monkeypatch.delenv("BOR_DOCS_REPO", raising=False) + monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs..") + monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "..") + s = _settings() + assert s.docs_configured is False + assert s.docs_branch == "bor docs.." # stored verbatim, never used diff --git a/tests/unit/test_doc_edit_screen.py b/tests/unit/test_doc_edit_screen.py new file mode 100644 index 0000000..c205cfd --- /dev/null +++ b/tests/unit/test_doc_edit_screen.py @@ -0,0 +1,545 @@ +"""Unit: the phase-59 task-06 doc edit screen (``/doc-edit.html``). + +No Python logic exists for this task — the behavior lives in +``frontend/doc-edit.html`` + ``frontend/assets/doc-edit.js`` + +``styles.css``, and it is E2E-gated by the story suite (task 07). Like +the other frontend-adjacent unit files (``test_history_page.py``, +``test_save_as_doc_button.py``), this module pins the HTML/JS/CSS +markers the edit loop depends on, so a silent regression is caught +without a browser: + +* the house shell (AGENTS.md rule 5 + the login.html/shared.html + minimal-flow-page lineage): skip-link, the SLIM header (brand + + "Back to chat" — no nav), the 46rem base column (hard-coded — a form + column, NOT ``--chat-column``), the ``container`` frame; +* the form contract: ``#draft-title`` / ``#draft-path`` / + ``#draft-body`` with visible labels, ``#push-doc-btn`` (the exact + "Push to docs branch" copy) + the back link, ``#push-status`` + (``role="status" aria-live="polite"``) and the hidden ``#push-error`` + (``role="alert"``); +* the admin gate — the ``sources-gate`` pattern, ship-hidden, with the + no-JS ``?next=`` fallback (the page is static; the API is the + authority — the draft endpoints are admin-only regardless); +* the JS: the whoami gate (anonymous branch makes NO ``/api/doc-drafts`` + call), the token handling (missing → "No draft specified.", + non-uuid → "Draft not found." with no fetch), the three API paths + (GET draft / PUT edits / POST push — the PUT runs BEFORE the push: + the endpoint commits the row, so unsaved edits would push stale + text), the §7.4 never-stale lifecycle (disable + "Pushing…", + re-enable in the finally), the success line + (``Pushed to — commit .``), the failure banner + (git's stderr trimmed to its first meaningful lines, fields + preserved), and VALUES-not-innerHTML everywhere. + +The Containerfile stage-1 coverage (doc-edit.html copied, doc-edit.js +bundled) and the cache-busting registration (``/doc-edit.html`` in +``HTML_PAGES``) are pinned by ``test_containerfile_assets.py`` / +``test_caching.py``. +""" +from __future__ import annotations + +import re +from pathlib import Path + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +ASSETS = FRONTEND / "assets" +DOC_EDIT_HTML = FRONTEND / "doc-edit.html" +DOC_EDIT_JS = ASSETS / "doc-edit.js" +STYLES_CSS = ASSETS / "styles.css" + + +def _html() -> str: + assert DOC_EDIT_HTML.is_file(), "frontend/doc-edit.html is missing" + return DOC_EDIT_HTML.read_text(encoding="utf-8") + + +def _js() -> str: + assert DOC_EDIT_JS.is_file(), "frontend/assets/doc-edit.js is missing" + return DOC_EDIT_JS.read_text(encoding="utf-8") + + +def _css() -> str: + return STYLES_CSS.read_text(encoding="utf-8") + + +def _fn(js: str, name: str) -> str: + """The source of a top-level ``function (...)`` (to its close).""" + start = js.find(f"function {name}(") + assert start != -1, f"{name}() must exist in doc-edit.js" + return js[start : js.find("\n}\n", start) + 4] + + +# ---------- the house shell (AGENTS.md rule 5) ---------- + + +def test_page_scaffold_slim_header_and_landmarks() -> None: + """The minimal-flow-page scaffold (the login.html/shared.html + lineage): skip-link, the SLIM header (brand + the "Back to chat" + link to / — and NO nav: this is a flow page, not one of the app's + pages), ``
`` with + the ``container`` frame, and the house footer.""" + html = _html() + assert '' in html + assert 'class="app-header"' in html + # The slim header: the brand + the back link. + assert 'Brain of Reese' in html + back = re.search(r']*class="doc-edit-back"[^>]*href="/"[^>]*>', html) + assert back, "the header must carry the 'Back to chat' link to /" + assert "Back to chat" in html + # And NO nav — the flow-page lineage (no hamburger, no app-nav). + assert 'id="app-nav"' not in html, "the slim header ships no nav" + assert 'id="nav-toggle"' not in html, "the slim header ships no hamburger" + assert "
" in html + assert '
' in html + assert 'class="app-footer"' in html + + +def test_page_title_and_description() -> None: + """The page's identity: the house title shape ( · Brain of + Reese) + a description naming the admin-only flow.""" + html = _html() + assert "Edit doc · Brain of Reese" in html + desc = re.search(r' None: + """The three fields (task 06): #draft-title (text), #draft-path + (text), #draft-body (textarea) — each with a VISIBLE + ``