Phase 49 (owner request, chat 2026-08-28: "The git sources page should remove local directory and should instead accept a tarball or zipfile upload which it will unpack and scan … reuploading the same tarball should not create a new folder, but should unpack and overwrite the previously unpacked content" — design confirmed in the same conversation): * POST /api/git-sources/upload (admin-only, require_admin): accepts .tar/.tar.gz/.tgz/.zip, streams it with the BOR_UPLOAD_MAX_MB cap (bounds BOTH the compressed upload and the total extracted bytes — zip-bomb guard), safely unpacks (absolute/traversal/symlink/hardlink escape and device/FIFO members rejected), and atomically swaps the content in over BOR_UPLOAD_DIR/<name>/ (name = filename minus the archive suffix — no missing window, a failed upload never touches the existing folder/row/KB). The git_sources row is upserted by path (kind='local', no duplicates, added_at preserved), the models are checked fail-fast (503 sanitized when down — the folder/row stay committed and the next sync/re-upload retries idempotently), and the source is scanned synchronously in the request (single-source import_sources prune=True + change-gated KB overview), answering 200 with the sync-style counts. One upload at a time (409); the request session is released before the scan so a concurrent TRUNCATE cannot deadlock against it. * app/rag/archive_upload.py: ArchiveUploadError, ARCHIVE_SUFFIXES, archive_source_name (safe-name derivation), unpack_archive (guarded zip/tar extraction with the extracted-byte cap, no partial state), swap_in (atomic replace with restore-on-failure) — fully unit-tested. * app/config.py + .env.example: BOR_UPLOAD_DIR (default ~/bor-sources/uploads, deliberately separate from the git checkouts) and BOR_UPLOAD_MAX_MB (default 512; a validator fails loud at startup on <= 0). * python-multipart added to the dependencies — FastAPI's required multipart parser (an A2 implementation detail, phase locked decision). * The Sources page: the phase-38 "Add a local directory" form is removed; #archive-upload-form takes its place (labeled file input, "Upload & scan" button, the §7.4 never-stale lifecycle, inline role=alert error, role=status count line); hint + table caption updated. The POST /api/git-sources kind=local API contract is UNCHANGED — a plain directory is still registrable via the API, and existing Local rows list/remove/sync exactly as before. * The phase-38 story E2E (test_local_directory_sources.py) is rewritten API-driven — the form it drove is gone; its acceptance stands. * The story E2E (test_archive_upload_sources.py): the swap, upload→scan→list (the deterministic "Uploading…" in-flight state, the Local row, /api/docs + the RAG catalog), same-filename re-upload (in-place replace, prune, no duplicate row, v2-only folder), the 422 inline error + recovery (the form is not wedged), and the anonymous gate + 403. * README: the archive-upload section (formats, naming rule, in-place replace, both new settings), the local-directory form removal noted, config reference rows for BOR_UPLOAD_DIR / BOR_UPLOAD_MAX_MB. Gates: unit+integration green, app/ coverage 99%, the story E2E green in isolation, the regression suites (git sources admin, local directory sources, sync button, import documents, nav rename, smoke, shared header) green in isolation, ruff + pyright clean. Note: per this phase's file-level staging, frontend/assets/styles.css also carries the small same-day in-flight owner rework already in the working tree (the .sign-in-mobile companion rule for the phase-48 mobile sign-in copy); the phase-49 change is the upload form's block.
558 lines
23 KiB
Python
558 lines
23 KiB
Python
"""Phase 49 story E2E (Playwright): archive upload sources.
|
|
|
|
Story: ``.agent/user_stories/archive-upload-sources.md``
|
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
|
|
|
uv run pytest tests/e2e/test_archive_upload_sources.py -v --no-cov
|
|
|
|
The story gate for the **archive upload** form on the admin Sources page
|
|
(``/git-sources.html``, phase 49 — the phase-38 "Add a local directory"
|
|
form is gone, replaced by this form): an uploaded ``.tar``/``.tar.gz``/
|
|
``.tgz``/``.zip`` is safely unpacked under ``BOR_UPLOAD_DIR/<name>/``
|
|
(name = filename minus the archive suffix), the ``git_sources`` row is
|
|
upserted (``kind='local'``, no duplicates), and the source is **scanned
|
|
synchronously in the request** (single-source ``import_sources`` with
|
|
``prune=True`` + the change-gated overview refresh) — the real pipeline,
|
|
against the deterministic mock LLM (no real models, no network beyond
|
|
the app itself).
|
|
|
|
The archives are **built in-test** with Python's ``tarfile`` over
|
|
``tmp_path`` fixture files carrying markdown sentinels (``ALPHA-…`` /
|
|
``BETA-…`` / ``GAMMA-…``) and are always named
|
|
``e2e-upload.tar.gz`` — so the source name is ``e2e-upload`` and
|
|
re-uploading under the same filename exercises the in-place replace
|
|
(one folder, one row, dropped files pruned from the KB). ``v1`` holds
|
|
``alpha.md`` + ``beta.md``; ``v2`` (same basename) modifies ``alpha``,
|
|
drops ``beta``, adds ``gamma``.
|
|
|
|
Per-module app env (the conftest pattern, module-scoped — as in
|
|
``test_git_sources_admin.py`` / ``test_local_directory_sources.py``):
|
|
``BOR_UPLOAD_DIR`` points at a scratch dir the suite can inspect from
|
|
the host (the app runs on the same machine), and
|
|
``BOR_GIT_SOURCES`` is forced empty so the dev ``.env``'s fallback URL
|
|
never renders as an env row on the (initially empty) table.
|
|
|
|
Contract under test:
|
|
|
|
* the **swap** (task 03): the phase-38 local form is gone (count 0);
|
|
the upload form is in its place with the labeled file input (accept
|
|
= the four archive extensions), the "Upload & scan" button, and the
|
|
hint explains unpack/scan + in-place replace;
|
|
* **upload → scan → list** (§7.4 never-stale): the button shows
|
|
"Uploading…" while the POST is in flight (the request is held in the
|
|
browser via ``page.route`` so the in-flight state is deterministic),
|
|
then restores; the result line shows the added count; the list gains
|
|
exactly one row for ``e2e-upload`` with the **Local** badge;
|
|
``GET /api/docs`` lists both sentinel files under source
|
|
``e2e-upload``; the RAG catalog (``/sources.html``) shows them;
|
|
* **re-upload, same filename** → in-place replace: the result line
|
|
shows the prune, the list still has exactly ONE ``e2e-upload`` row
|
|
(no duplicate), the KB shows the changed ``alpha`` + the new
|
|
``gamma`` and NOT the dropped ``beta``, and the on-disk folder holds
|
|
only the new archive's files;
|
|
* **bad file** → inline 422 (role=alert) naming the accepted formats,
|
|
button restored, the file selection kept, the list unchanged, and a
|
|
subsequent good upload still works (the form is not wedged);
|
|
* **anonymous** → the sign-in gate (``#git-sources-gate``) shows, the
|
|
manager (and thus the upload form) stays hidden, and
|
|
``POST /api/git-sources/upload`` is 403.
|
|
|
|
Test → story mapping (Playwright Mapping Rule):
|
|
1. ``test_form_swapped``
|
|
2. ``test_upload_scans_and_lists``
|
|
3. ``test_reupload_replaces_in_place``
|
|
4. ``test_bad_file_inline_error``
|
|
5. ``test_anonymous_gate``
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from playwright.sync_api import Page, expect
|
|
from sqlalchemy import text
|
|
|
|
from app.db import SessionLocal
|
|
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]
|
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
|
|
|
GIT_SOURCES_URL = "/git-sources.html"
|
|
SOURCES_URL = "/sources.html"
|
|
|
|
#: The archive basename (both versions) — the source/folder name is the
|
|
#: filename minus the archive suffix (the phase's locked naming rule).
|
|
SOURCE_NAME = "e2e-upload"
|
|
|
|
#: v1: two sentinel docs. v2 (same filename): alpha CHANGED, beta DROPPED,
|
|
#: gamma ADDED — the in-place-replace subject.
|
|
ALPHA_SENTINEL_V1 = "ALPHA-TOKEN-v1-7f31"
|
|
ALPHA_SENTINEL_V2 = "ALPHA-TOKEN-v2-8b42"
|
|
BETA_SENTINEL_V1 = "BETA-TOKEN-v1-2c90"
|
|
GAMMA_SENTINEL_V2 = "GAMMA-TOKEN-v2-5e44"
|
|
|
|
V1_FILES: dict[str, str] = {
|
|
"alpha.md": (
|
|
"# Alpha note\n"
|
|
"\n"
|
|
"First version of the alpha note — it changes in v2.\n"
|
|
f"\nMarker: {ALPHA_SENTINEL_V1}\n"
|
|
),
|
|
"beta.md": (
|
|
"# Beta note\n"
|
|
"\n"
|
|
"Only present in v1 — v2 drops it (the prune subject).\n"
|
|
f"\nMarker: {BETA_SENTINEL_V1}\n"
|
|
),
|
|
}
|
|
V2_FILES: dict[str, str] = {
|
|
"alpha.md": (
|
|
"# Alpha note\n"
|
|
"\n"
|
|
"Second version of the alpha note — modified in place.\n"
|
|
f"\nMarker: {ALPHA_SENTINEL_V2}\n"
|
|
),
|
|
"gamma.md": (
|
|
"# Gamma note\n"
|
|
"\n"
|
|
"Brand new in v2 — the add subject of the re-upload.\n"
|
|
f"\nMarker: {GAMMA_SENTINEL_V2}\n"
|
|
),
|
|
}
|
|
|
|
#: The scan runs the full pipeline against the mock LLM (models probe +
|
|
#: embed batch + per-doc summaries + the change-gated overview) —
|
|
#: generous, like the sync suites; no client-side hard timeout.
|
|
UPLOAD_TIMEOUT_MS = 90_000
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_targz(path: Path, files: dict[str, str]) -> Path:
|
|
"""A deterministic ``.tar.gz`` (mtime 0) over the given files."""
|
|
with tarfile.open(path, "w:gz") as tf:
|
|
for rel, content in files.items():
|
|
data = content.encode("utf-8")
|
|
info = tarfile.TarInfo(rel)
|
|
info.size = len(data)
|
|
info.mtime = 0
|
|
tf.addfile(info, io.BytesIO(data))
|
|
return path
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def upload_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""The app's ``BOR_UPLOAD_DIR`` for this suite — a scratch dir the
|
|
host-side assertions inspect (the app server runs on the same
|
|
machine). The app creates it on the first upload."""
|
|
return tmp_path_factory.mktemp("bor_uploads") / "uploads"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def tarball_v1(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""v1 — in its OWN subdirectory so v2 can reuse the same basename
|
|
(``e2e-upload.tar.gz``): the in-place-replace identity IS the
|
|
filename, and ``set_input_files`` sends the path's basename."""
|
|
root = tmp_path_factory.mktemp("bor_archive_v1")
|
|
return _build_targz(root / f"{SOURCE_NAME}.tar.gz", V1_FILES)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def tarball_v2(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""v2 — same basename as v1 (a different parent dir)."""
|
|
root = tmp_path_factory.mktemp("bor_archive_v2")
|
|
return _build_targz(root / f"{SOURCE_NAME}.tar.gz", V2_FILES)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def app_server(
|
|
mock_llm: int,
|
|
upload_dir: Path,
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> Iterator[str]:
|
|
"""The real app under test — per-module env: uploads unpack into a
|
|
scratch dir and the env git list is forced empty (the dev ``.env``'s
|
|
``BOR_GIT_SOURCES`` must not render as env rows on the initially
|
|
empty table). No sync is triggered here — the upload's own scan is
|
|
the pipeline under test."""
|
|
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_llm}/v1"
|
|
)
|
|
# Mock-calibrated threshold (conftest pattern) — no chat turn is
|
|
# ever sent in this suite, but the app boots with the same env shape.
|
|
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
|
|
env["BOR_GIT_SOURCES"] = ""
|
|
# Phase 49: unpack uploads into the suite's scratch dir (host-
|
|
# inspectable) and keep the (unused) git checkouts out of the dev
|
|
# location.
|
|
env["BOR_UPLOAD_DIR"] = str(upload_dir)
|
|
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
|
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
|
cwd=REPO,
|
|
env=env,
|
|
)
|
|
try:
|
|
_wait_http(f"{APP_URL}/api/health")
|
|
yield APP_URL
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def app_url(app_server: str) -> str:
|
|
return app_server
|
|
|
|
|
|
def _truncate_all() -> None:
|
|
"""Fresh registry + KB per test (the E2E isolation pattern): the
|
|
upload's counts and every ``/api/docs`` assertion must be this
|
|
test's own doing. The E2E suites share one Postgres, and a leftover
|
|
git_sources row or document would corrupt the row-count and doc-list
|
|
assertions (and a leftover document under the same source name would
|
|
survive the re-upload's single-source prune)."""
|
|
with SessionLocal() as db:
|
|
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean(db_ready: None) -> Iterator[None]:
|
|
_truncate_all()
|
|
yield
|
|
_truncate_all()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _admin_git_sources_page(page: Page, app_url: str) -> None:
|
|
"""Real form login landing on the git sources page (admin settled:
|
|
Sign out visible, the manager revealed by the page module)."""
|
|
login(page, app_url, next=GIT_SOURCES_URL)
|
|
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#git-sources-gate")).to_be_hidden()
|
|
expect(page.locator("#git-sources-content")).to_be_visible()
|
|
|
|
|
|
def _docs(page: Page, app_url: str) -> list[tuple[str, str]]:
|
|
"""``GET /api/docs`` as the signed-in page → sorted (source, path)
|
|
pairs (the admin cookie rides the browser context)."""
|
|
r = page.request.get(f"{app_url}/api/docs")
|
|
assert r.status == 200, r.text
|
|
return sorted((d["source"], d["path"]) for d in r.json()["documents"])
|
|
|
|
|
|
def _upload_via_page(page: Page, archive: Path) -> str:
|
|
"""Pick the archive, submit the form, and wait for the result line
|
|
(the 200 path) — returns its text. The failing path is asserted
|
|
explicitly by the bad-file test, so any non-result outcome here is
|
|
a test error."""
|
|
page.set_input_files("#archive-upload-file", str(archive))
|
|
page.click("#archive-upload-btn")
|
|
result = page.locator("#archive-upload-result")
|
|
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
|
text = result.text_content()
|
|
assert text is not None
|
|
return text
|
|
|
|
|
|
def _hold_upload_request(page: Page, hold_s: float) -> None:
|
|
"""Intercept the upload POST and hold the REQUEST in the browser for
|
|
``hold_s`` seconds before letting it reach the server. While it is
|
|
held, the page's fetch is guaranteed pending — so the §7.4 in-flight
|
|
state (disabled button, "Uploading…" label) is observable
|
|
deterministically instead of racing the mock LLM's fast scan."""
|
|
|
|
def handle(route: Any) -> None:
|
|
time.sleep(hold_s)
|
|
route.continue_()
|
|
|
|
page.route("**/api/git-sources/upload", handle)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. The swap: local form out, upload form in
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_form_swapped(page: Page, app_url: str, db_ready: None) -> None:
|
|
"""The phase-38 "Add a local directory" form is GONE and the archive
|
|
upload form stands in its place: visible file input (accept = the
|
|
four archive extensions), the "Upload & scan" button, and a hint
|
|
that explains the unpack/scan + in-place-replace semantics."""
|
|
page.set_default_timeout(30_000)
|
|
_admin_git_sources_page(page, app_url)
|
|
|
|
# The phase-38 local form is gone (phase 49 replaced it)…
|
|
expect(page.locator("#local-source-form")).to_have_count(0)
|
|
expect(page.locator("#local-source-path")).to_have_count(0)
|
|
expect(page.locator("#local-source-add")).to_have_count(0)
|
|
|
|
# …and the upload form is in its place, visible with its parts.
|
|
expect(page.locator("#archive-upload-form")).to_be_visible()
|
|
file_input = page.locator("#archive-upload-file")
|
|
expect(file_input).to_be_visible()
|
|
accept = file_input.get_attribute("accept") or ""
|
|
for ext in (".tar", ".tar.gz", ".tgz", ".zip"):
|
|
assert ext in accept, f"accept={accept!r} is missing {ext!r}"
|
|
btn = page.locator("#archive-upload-btn")
|
|
expect(btn).to_be_visible()
|
|
expect(btn).to_be_enabled()
|
|
expect(btn).to_have_text("Upload & scan")
|
|
# The error/result lines ship (hidden) with the right roles.
|
|
assert page.locator("#archive-upload-error").get_attribute("role") == "alert"
|
|
result = page.locator("#archive-upload-result")
|
|
assert result.get_attribute("role") == "status"
|
|
expect(result).to_be_hidden()
|
|
|
|
# The hint explains unpack/scan + in-place replace (task 03).
|
|
hint = page.locator("#git-sources-hint")
|
|
expect(hint).to_be_visible()
|
|
expect(hint).to_contain_text("unpack")
|
|
expect(hint).to_contain_text("scan")
|
|
expect(hint).to_contain_text("in place")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Upload → scan → list (the §7.4 in-flight state, the counts, the
|
|
# Local row, the KB, the RAG catalog)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_upload_scans_and_lists(
|
|
page: Page, app_url: str, db_ready: None, tarball_v1: Path, upload_dir: Path
|
|
) -> None:
|
|
"""One real upload through the page: while the POST is in flight the
|
|
button is disabled and reads "Uploading…"; on the 200 it restores,
|
|
the result line shows the added count (2), the file input clears,
|
|
the list gains exactly ONE row for ``e2e-upload`` with the Local
|
|
badge, ``/api/docs`` lists both sentinel files under the source, and
|
|
the RAG catalog shows them where the admin expects them."""
|
|
page.set_default_timeout(30_000)
|
|
_admin_git_sources_page(page, app_url)
|
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
|
|
|
|
btn = page.locator("#archive-upload-btn")
|
|
result = page.locator("#archive-upload-result")
|
|
|
|
# Hold the upload request in the browser: the in-flight state below
|
|
# cannot race the (fast) mock-LLM scan while it is held.
|
|
_hold_upload_request(page, hold_s=0.8)
|
|
page.set_input_files("#archive-upload-file", str(tarball_v1))
|
|
btn.click()
|
|
|
|
# In flight (§7.4): disabled + relabeled, no result yet.
|
|
expect(btn).to_be_disabled()
|
|
expect(btn).to_have_text("Uploading…")
|
|
expect(result).to_be_hidden()
|
|
|
|
# The request goes out, the server unpacks + scans (mock LLM) and
|
|
# answers 200 → the result line shows the added count.
|
|
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
|
expect(result).to_have_text("2 added")
|
|
|
|
# Never stale: the button restored on success and the input cleared.
|
|
expect(btn).to_be_enabled()
|
|
expect(btn).to_have_text("Upload & scan")
|
|
expect(page.locator("#archive-upload-file")).to_have_value("")
|
|
|
|
# The list gained exactly one row — for the source, with the Local
|
|
# badge and the full unpacked path in the mono cell.
|
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(1, timeout=30_000)
|
|
row = page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)
|
|
expect(row).to_have_count(1)
|
|
expect(row.locator("span.git-source-kind")).to_have_text("Local")
|
|
expect(row.locator("td.git-source-url-cell code")).to_have_text(
|
|
str(upload_dir / SOURCE_NAME)
|
|
)
|
|
|
|
# The KB: both sentinel files, under the source name e2e-upload.
|
|
assert _docs(page, app_url) == [(SOURCE_NAME, "alpha.md"), (SOURCE_NAME, "beta.md")]
|
|
|
|
# The RAG catalog (admin sees it): both docs, under the source.
|
|
page.goto(app_url + SOURCES_URL)
|
|
expect(page.locator("#docs-tbody tr")).to_have_count(2)
|
|
expect(page.locator("#docs-tbody tr", has_text="alpha.md")).to_have_count(1)
|
|
expect(page.locator("#docs-tbody tr", has_text="beta.md")).to_have_count(1)
|
|
expect(page.locator("#docs-tbody tr", has_text=SOURCE_NAME)).to_have_count(2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Re-upload, same filename → in-place replace (no duplicate row,
|
|
# dropped file pruned, changed/new file indexed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_reupload_replaces_in_place(
|
|
page: Page,
|
|
app_url: str,
|
|
db_ready: None,
|
|
tarball_v1: Path,
|
|
tarball_v2: Path,
|
|
upload_dir: Path,
|
|
) -> None:
|
|
"""v1 then v2 under the SAME filename (``e2e-upload.tar.gz``): the
|
|
result line shows the prune, the list still has exactly ONE
|
|
``e2e-upload`` row (the row count for that source is invariant — no
|
|
duplicate), the KB shows the changed ``alpha`` + the new ``gamma``
|
|
and NOT the dropped ``beta``, and the on-disk folder holds only the
|
|
new archive's files."""
|
|
page.set_default_timeout(30_000)
|
|
_admin_git_sources_page(page, app_url)
|
|
|
|
# Baseline: v1 through the page (200 → "2 added", one row).
|
|
assert _upload_via_page(page, tarball_v1) == "2 added"
|
|
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
|
|
|
# Re-upload v2 — SAME basename, different parent dir (the file
|
|
# input's selection is replaced wholesale).
|
|
assert _upload_via_page(page, tarball_v2) is not None
|
|
result = page.locator("#archive-upload-result")
|
|
expect(result).to_have_text(re.compile(r"\d+ pruned"))
|
|
|
|
# No duplicate: exactly ONE row for that source (and one row total).
|
|
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
|
# The registry agrees: one kind=local row, the unpacked path.
|
|
r = page.request.get(f"{app_url}/api/git-sources")
|
|
assert r.status == 200, r.text
|
|
body = r.json()
|
|
assert [(s["kind"], s["path"]) for s in body["sources"]] == [
|
|
("local", str(upload_dir / SOURCE_NAME))
|
|
]
|
|
|
|
# The KB: gamma + the CHANGED alpha, NOT the dropped beta.
|
|
assert _docs(page, app_url) == [(SOURCE_NAME, "alpha.md"), (SOURCE_NAME, "gamma.md")]
|
|
# …and the indexed alpha is the v2 one (in-place replace, proven in
|
|
# the KB, not just the filesystem).
|
|
content = page.request.get(
|
|
f"{app_url}/api/documents/content?source={SOURCE_NAME}&path=alpha.md"
|
|
)
|
|
assert content.status == 200, content.text
|
|
assert ALPHA_SENTINEL_V2 in content.json()["content"]
|
|
assert ALPHA_SENTINEL_V1 not in content.json()["content"]
|
|
|
|
# The on-disk folder holds ONLY v2's files (the swap replaced the
|
|
# whole folder — no stale v1 file survived).
|
|
folder = upload_dir / SOURCE_NAME
|
|
assert {p.name for p in folder.iterdir()} == set(V2_FILES)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Bad file → inline 422; the form is not wedged
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bad_file_inline_error(
|
|
page: Page, app_url: str, db_ready: None, tarball_v1: Path, tmp_path: Path
|
|
) -> None:
|
|
"""A ``.txt`` through the file input: the role=alert line shows the
|
|
422 detail naming the accepted formats, the button restores, the
|
|
file selection is KEPT (the fix is one re-pick), the list is
|
|
unchanged — and a subsequent good upload still works (the form is
|
|
not wedged)."""
|
|
page.set_default_timeout(30_000)
|
|
_admin_git_sources_page(page, app_url)
|
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
|
|
|
|
bad = tmp_path / "notes.txt"
|
|
bad.write_text("I am not an archive.\n", encoding="utf-8")
|
|
|
|
error = page.locator("#archive-upload-error")
|
|
btn = page.locator("#archive-upload-btn")
|
|
page.set_input_files("#archive-upload-file", str(bad))
|
|
btn.click()
|
|
|
|
# The 422 detail inline (role=alert), naming the accepted formats.
|
|
expect(error).to_be_visible(timeout=30_000)
|
|
assert error.get_attribute("role") == "alert"
|
|
expect(error).to_contain_text("only .tar, .tar.gz, .tgz or .zip archives are accepted")
|
|
|
|
# Never stale + the selection kept + no result line + list unchanged.
|
|
expect(btn).to_be_enabled()
|
|
expect(btn).to_have_text("Upload & scan")
|
|
# The selection is kept (the fix is one re-pick) — Chromium reports
|
|
# a fake path (``…/notes.txt``), so assert on the basename.
|
|
bad_value = page.locator("#archive-upload-file").input_value()
|
|
assert bad_value.endswith("notes.txt"), bad_value
|
|
expect(page.locator("#archive-upload-result")).to_be_hidden()
|
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
|
|
|
|
# The form is not wedged: a good upload right after still works.
|
|
assert _upload_via_page(page, tarball_v1) == "2 added"
|
|
expect(error).to_be_hidden()
|
|
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
|
assert _docs(page, app_url) == [(SOURCE_NAME, "alpha.md"), (SOURCE_NAME, "beta.md")]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Anonymous: the gate, the hidden manager, the 403
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_anonymous_gate(page: Page, app_url: str, db_ready: None) -> None:
|
|
"""Anonymous on the page: the sign-in gate shows, the manager (and
|
|
thus the upload form) stays hidden, and the upload route 403s
|
|
(``require_admin`` — A10)."""
|
|
page.set_default_timeout(30_000)
|
|
|
|
page.goto(app_url + GIT_SOURCES_URL)
|
|
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
|
|
|
gate = page.locator("#git-sources-gate")
|
|
expect(gate).to_be_visible()
|
|
expect(gate).to_contain_text("Sign in to manage the git sources")
|
|
|
|
# The manager is hidden — so is the upload form inside it.
|
|
expect(page.locator("#git-sources-content")).to_be_hidden()
|
|
expect(page.locator("#archive-upload-form")).to_be_hidden()
|
|
|
|
# The upload route 403s anonymous callers (require_admin runs before
|
|
# the multipart body is parsed — the body is a stand-in, the
|
|
# test_local_directory_sources.py pattern for this route).
|
|
r = page.request.post(f"{app_url}/api/git-sources/upload", data={"file": ""})
|
|
assert r.status == 403
|