agnes-the-ai-analyst/tests/test_initial_workspace_api.py
minasarustamyan 69a1e22cf5
feat(initial-workspace): per-instance agnes init override (#292)
* feat(initial-workspace): per-instance agnes init override

Adds Initial Workspace Template — an admin-configurable per-instance
override for the agnes init analyst workspace. When configured, agnes
init downloads a server-rendered zip from a Git repo the admin registered
and extracts it into the analyst's workspace, fully bypassing Agnes-default
CLAUDE.md / settings.json / hooks / slash commands / AGNES_WORKSPACE.md.

Repo layout convention: only the contents of a top-level `workspace/`
subdirectory ship to analysts; admin docs (README, CI configs) at the
repo root stay in the repo and never reach an analyst. Sync rejects
repos without `workspace/` at root.

Server side:
- src/initial_workspace.py — clone (or fetch+reset), validate, build zip
  with strict path checks and reserved-path rejection
  (workspace/.claude/init-complete reserved by Agnes)
- app/api/initial_workspace.py — admin CRUD + sync endpoint + analyst-
  facing status/zip/applied endpoints; config persists to instance.yaml
  overlay, PAT to .env_overlay
- app/secrets.py — refactor: persist_overlay_token shared helper with
  threading.Lock for .env_overlay writes (closes pre-existing race
  between concurrent marketplaces saves)
- app/web/templates/admin_server_config.html — new "Initial Workspace
  Template" section + modal + Sync/Edit/Delete/Download buttons (matches
  existing cfg-section visual language)

CLI side:
- cli/lib/override.py — single source of truth for is_override_workspace
  sentinel detection
- cli/lib/initial_workspace.py — probe status, safe zip extraction with
  ../absolute/symlink rejection, typed-YES force confirmation
- cli/commands/init.py — override branch (skips Agnes-default workspace
  writes); extended sentinel with override:true, template_source,
  template_sha so future agnes self-upgrade does not auto-refresh hooks
- cli/lib/hooks.py + cli/lib/commands.py — short-circuit on override
  workspaces (install_claude_hooks, install_claude_commands,
  maybe_refresh_claude_hooks)

Audit-event strategy: server writes initial_workspace.fetch_started
inside GET /api/initial-workspace.zip (cannot be spoofed by PAT-holder);
CLI POST /applied writes initial_workspace.applied as best-effort
confirmation. Admin mutations log via the existing _audit pattern.

Tests: 27 server (clone/validate/zip + workspace-subdir convention +
concurrent persist_overlay_token + endpoint shapes + audit rows) + 29
CLI (override sentinel parse + probe fall-through + safe extraction +
YES strictness + hook guards + e2e mocked init).

Risk acceptance — documented in docs/initial-workspace-override.md +
CHANGELOG Internal section so AI reviewers understand the deviations
from defaults are intentional:
- maybe_refresh_claude_hooks deliberately no-ops on override workspaces
- --force on override does NOT back up CLAUDE.md (admin's repo is the
  source of truth)
- .claude/CLAUDE.local.md IS overwritten by override extraction when
  admin's repo ships one

* test+vendor-agnostic: drop Groupon tokens from #292 fixtures + extend admin-gate coverage

Two fixes from the takeover review on #292:

1. **Vendor-agnostic OSS rule**: Replace `Groupon` / `groupon/template`
   tokens in test fixtures with `Acme` / `acme/template` (8 sites in
   test_cli_init_override.py + 1 in test_initial_workspace_api.py).
   Per CLAUDE.md "Vendor-agnostic OSS — no customer-specific content"
   rule: customer-specific tokens don't belong in shipped artifacts,
   even in test fixtures. The pre-existing FoundryAI mentions in
   test_instance_config.py + test_setup_instructions.py are out of
   scope for this PR (didn't introduce them).

2. **Admin-gate coverage gap**: `test_admin_endpoints_require_admin`
   only covered GET /api/admin/initial-workspace + POST .../sync. The
   register-write (POST .../initial-workspace) and delete (DELETE
   .../initial-workspace) endpoints used the same `Depends(require_admin)`
   wiring but had no regression test. Loop now covers all 4 verbs so
   a future refactor that drops the dependency from one endpoint
   fails here instead of silently exposing the write/delete paths to
   any analyst with a PAT.

* release: 0.54.9 — Initial Workspace Template (per-instance agnes init override)

Last commit on the PR per CLAUDE.md hard rule. Patch bump (0.54.8 →
0.54.9) for Mina's Initial Workspace Template feature.

No DB migration (config lives in instance.yaml overlay). No
mandatory operator action — empty default keeps OSS-default
agnes init behavior. Operators wanting full template control link a
Git repo on /admin/server-config → "Initial Workspace Template".
See docs/initial-workspace-override.md for the full
responsibility-transfer contract.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-13 20:35:01 +00:00

692 lines
25 KiB
Python

"""Tests for the per-instance Initial Workspace Template feature.
Covers:
* `src.initial_workspace`: clone, validate, zip
* `app.api.initial_workspace`: admin + analyst endpoints
* `app.secrets.persist_overlay_token`: lock + correctness for the
shared overlay-write helper (introduced as a prerequisite refactor)
Uses a local bare git repo as fake remote so no network is needed.
Pattern copied from `tests/test_marketplace.py`.
"""
from __future__ import annotations
import json
import os
import subprocess
import threading
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Fake-remote helpers (mirror tests/test_marketplace.py)
# ---------------------------------------------------------------------------
def _git(*args: str, cwd: Path | None = None, env: dict | None = None) -> str:
full_env = {**os.environ, **(env or {})}
full_env.setdefault("GIT_AUTHOR_NAME", "Test")
full_env.setdefault("GIT_AUTHOR_EMAIL", "test@example.com")
full_env.setdefault("GIT_COMMITTER_NAME", "Test")
full_env.setdefault("GIT_COMMITTER_EMAIL", "test@example.com")
result = subprocess.run(
["git", *args],
cwd=str(cwd) if cwd else None,
env=full_env,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def _file_url(path: Path) -> str:
return path.resolve().as_uri()
@pytest.fixture
def fake_remote(tmp_path: Path):
"""Create a bare repo with a `workspace/` subdir containing
CLAUDE.md + .claude/settings.json so the initial-workspace tests
have something realistic to clone.
Repo layout convention: only `workspace/` content reaches the
analyst. Anything else at repo root (README, admin docs) is admin
territory and never shipped.
"""
work = tmp_path / "src-work"
work.mkdir()
_git("init", "-b", "main", cwd=work)
# Repo-root file — admin-only, NOT shipped to analyst
(work / "README.md").write_text("# Admin docs (not shipped)\n", encoding="utf-8")
# Workspace subdir — this is what reaches the analyst
workspace = work / "workspace"
workspace.mkdir()
(workspace / "CLAUDE.md").write_text(
"# Custom Workspace\n\nInternal rules.\n", encoding="utf-8"
)
(workspace / ".claude").mkdir()
(workspace / ".claude" / "settings.json").write_text(
json.dumps(
{
"model": "sonnet",
"hooks": {
"SessionStart": [
{"hooks": [{"type": "command", "command": "agnes pull --quiet || true"}]}
],
},
},
indent=2,
),
encoding="utf-8",
)
_git("add", ".", cwd=work)
_git("commit", "-m", "initial", cwd=work)
bare = tmp_path / "remote.git"
_git("clone", "--bare", str(work), str(bare))
_git("remote", "add", "origin", str(bare), cwd=work)
sha = _git("rev-parse", "HEAD", cwd=work)
return {"bare": bare, "work": work, "url": _file_url(bare), "sha": sha}
# ---------------------------------------------------------------------------
# Environment fixture — fresh DATA_DIR + system.duckdb per test
# ---------------------------------------------------------------------------
@pytest.fixture
def clean_env(tmp_path: Path, monkeypatch):
data_dir = tmp_path / "data"
(data_dir / "state").mkdir(parents=True)
(data_dir / "initial-workspace").mkdir(exist_ok=True)
monkeypatch.setenv("DATA_DIR", str(data_dir))
import src.db as db
if getattr(db, "_system_db_conn", None) is not None:
try:
db._system_db_conn.close()
except Exception:
pass
db._system_db_conn = None
db._system_db_path = None
yield data_dir
# ===========================================================================
# Layer 1: src/initial_workspace.py — clone, validate, zip
# ===========================================================================
def test_sync_template_clones_fresh(clean_env, fake_remote):
"""Fresh clone lands content in ${DATA_DIR}/initial-workspace/.
Repo layout: README.md at root + workspace/ subdir with workspace
content. After clone, both are on disk; only workspace/ ships to
analysts via build_zip / list_template_files.
"""
from src.initial_workspace import sync_template
result = sync_template(url=fake_remote["url"], branch="main")
assert result["commit_sha"] == fake_remote["sha"]
target = clean_env / "initial-workspace"
# Both root-level admin docs AND workspace/ subdir exist on disk
assert (target / "README.md").exists()
assert (target / "workspace" / "CLAUDE.md").exists()
assert (target / "workspace" / ".claude" / "settings.json").exists()
assert (target / ".git").is_dir()
def test_sync_template_fetch_reset_on_resync(clean_env, fake_remote):
"""Second sync uses fetch+reset (not re-clone). New commit reflected."""
from src.initial_workspace import sync_template
sync_template(url=fake_remote["url"], branch="main")
# Add a commit upstream — file in workspace/ subdir
work = fake_remote["work"]
(work / "workspace" / "docs").mkdir(exist_ok=True)
(work / "workspace" / "docs" / "handbook.md").write_text(
"handbook\n", encoding="utf-8"
)
_git("add", ".", cwd=work)
_git("commit", "-m", "add handbook", cwd=work)
_git("push", "origin", "main", cwd=work)
new_sha = _git("rev-parse", "HEAD", cwd=work)
result = sync_template(url=fake_remote["url"], branch="main")
assert result["commit_sha"] == new_sha
target = clean_env / "initial-workspace"
assert (target / "workspace" / "docs" / "handbook.md").exists()
def test_validate_template_tree_rejects_reserved_path(tmp_path):
"""`workspace/.claude/init-complete` in repo is reserved — sync must reject."""
from src.initial_workspace import TemplateValidationError, validate_template_tree
root = tmp_path / "tree"
workspace = root / "workspace"
workspace.mkdir(parents=True)
(workspace / ".claude").mkdir()
(workspace / ".claude" / "init-complete").write_text("oops", encoding="utf-8")
with pytest.raises(TemplateValidationError) as exc:
validate_template_tree(root)
assert "init-complete" in str(exc.value)
assert "reserved" in str(exc.value).lower()
def test_validate_template_tree_requires_workspace_subdir(tmp_path):
"""A repo without `workspace/` at root is rejected — strict layout."""
from src.initial_workspace import TemplateValidationError, validate_template_tree
root = tmp_path / "tree"
root.mkdir()
(root / "CLAUDE.md").write_text("# At wrong location\n", encoding="utf-8")
# No workspace/ subdir
with pytest.raises(TemplateValidationError) as exc:
validate_template_tree(root)
assert "workspace" in str(exc.value).lower()
def test_validate_template_tree_ignores_root_files(tmp_path):
"""Files OUTSIDE workspace/ (README, CI configs) are silently ignored."""
from src.initial_workspace import validate_template_tree
root = tmp_path / "tree"
workspace = root / "workspace"
workspace.mkdir(parents=True)
(workspace / "CLAUDE.md").write_text("# Real content\n", encoding="utf-8")
# Root-level files — admin's territory, validator must not touch
(root / "README.md").write_text("# admin docs\n", encoding="utf-8")
(root / ".github").mkdir()
(root / ".github" / "workflows").mkdir()
(root / ".github" / "workflows" / "ci.yml").write_text("ci\n", encoding="utf-8")
# Even a "reserved" path at REPO ROOT is fine — only workspace/ scope matters
(root / ".claude").mkdir(exist_ok=True)
(root / ".claude" / "init-complete").write_text("not in workspace/\n", encoding="utf-8")
# Should NOT raise
validate_template_tree(root)
def test_sync_template_rejects_repo_without_workspace_subdir(clean_env, tmp_path):
"""End-to-end: a remote without workspace/ subdir fails sync."""
from src.initial_workspace import TemplateValidationError, sync_template
work = tmp_path / "src-work"
work.mkdir()
_git("init", "-b", "main", cwd=work)
(work / "CLAUDE.md").write_text("# at root\n", encoding="utf-8")
_git("add", ".", cwd=work)
_git("commit", "-m", "init", cwd=work)
bare = tmp_path / "remote.git"
_git("clone", "--bare", str(work), str(bare))
with pytest.raises(TemplateValidationError) as exc:
sync_template(url=_file_url(bare), branch="main")
assert "workspace" in str(exc.value).lower()
def test_sync_template_rejects_repo_with_reserved_path(clean_env, tmp_path):
"""A remote shipping workspace/.claude/init-complete is rejected."""
from src.initial_workspace import TemplateValidationError, sync_template
work = tmp_path / "src-work"
work.mkdir()
_git("init", "-b", "main", cwd=work)
workspace = work / "workspace"
workspace.mkdir()
(workspace / ".claude").mkdir()
(workspace / ".claude" / "init-complete").write_text("naughty", encoding="utf-8")
_git("add", ".", cwd=work)
_git("commit", "-m", "init", cwd=work)
bare = tmp_path / "remote.git"
_git("clone", "--bare", str(work), str(bare))
with pytest.raises(TemplateValidationError):
sync_template(url=_file_url(bare), branch="main")
def test_build_zip_excludes_root_files_and_git(clean_env, fake_remote):
"""Zip contains ONLY workspace/ contents, paths relative to workspace/.
Root-level README.md from the repo must NOT be in the zip.
"""
import io
import zipfile
from src.initial_workspace import build_zip, sync_template
sync_template(url=fake_remote["url"], branch="main")
data = build_zip()
names = sorted(zipfile.ZipFile(io.BytesIO(data)).namelist())
# Workspace content in, paths flattened (no workspace/ prefix)
assert "CLAUDE.md" in names
assert ".claude/settings.json" in names
# Admin-only root files must NOT leak into the zip
assert "README.md" not in names
assert not any(n.startswith("workspace/") for n in names)
assert not any(n.startswith(".git/") for n in names)
def test_list_template_files_deterministic(clean_env, fake_remote):
"""list_template_files returns sorted, deterministic POSIX paths
relative to workspace/."""
from src.initial_workspace import list_template_files, sync_template
sync_template(url=fake_remote["url"], branch="main")
files = list_template_files()
assert files == sorted(files)
assert "CLAUDE.md" in files
assert ".claude/settings.json" in files
# README.md at repo root must NOT be listed
assert "README.md" not in files
# ===========================================================================
# Layer 2: app/secrets.persist_overlay_token concurrency
# ===========================================================================
def test_persist_overlay_token_concurrent_writes(clean_env):
"""Two threads writing different keys produce a valid merged overlay.
Before the refactor, this test would intermittently fail because
marketplaces._persist_token had no lock. With the shared helper,
the lock guarantees both keys land in the final file.
"""
from app.secrets import persist_overlay_token, _state_dir
barrier = threading.Barrier(2)
errors: list[Exception] = []
def worker(key, value):
try:
barrier.wait(timeout=5)
# Hit the helper many times so the race window is wide enough
# to repro reliably on slow CI runners.
for _ in range(50):
persist_overlay_token(key, value)
except Exception as e:
errors.append(e)
t1 = threading.Thread(target=worker, args=("AGNES_KEY_A", "value_a"))
t2 = threading.Thread(target=worker, args=("AGNES_KEY_B", "value_b"))
t1.start()
t2.start()
t1.join()
t2.join()
assert errors == [], errors
overlay_text = (_state_dir() / ".env_overlay").read_text()
lines = [l for l in overlay_text.splitlines() if l]
pairs = dict(l.split("=", 1) for l in lines)
assert pairs.get("AGNES_KEY_A") == "value_a", pairs
assert pairs.get("AGNES_KEY_B") == "value_b", pairs
def test_persist_overlay_token_clear_removes_key(clean_env):
"""value=None and value='' both remove the key."""
from app.secrets import persist_overlay_token, _state_dir
persist_overlay_token("AGNES_TMP", "secret")
assert "AGNES_TMP" in (_state_dir() / ".env_overlay").read_text()
persist_overlay_token("AGNES_TMP", None)
assert "AGNES_TMP" not in (_state_dir() / ".env_overlay").read_text()
persist_overlay_token("AGNES_TMP", "back")
persist_overlay_token("AGNES_TMP", "")
assert "AGNES_TMP" not in (_state_dir() / ".env_overlay").read_text()
# ===========================================================================
# Layer 3: API endpoints (admin + analyst)
# ===========================================================================
@pytest.fixture
def web_client(clean_env, monkeypatch):
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-min-32-characters!!")
from fastapi.testclient import TestClient
from src.db import close_system_db
close_system_db()
from app.main import create_app
app = create_app()
yield TestClient(app)
close_system_db()
def _make_admin(client, email="admin@example.com"):
"""Create an admin user and return their auth headers."""
from argon2 import PasswordHasher
from src.db import get_system_db
from src.repositories.users import UserRepository
from src.repositories.user_group_members import UserGroupMembersRepository
ph = PasswordHasher()
conn = get_system_db()
UserRepository(conn).create(
id="admin", email=email, name="Admin", password_hash=ph.hash("AdminPass1!"),
)
# Admin group is seeded as is_system=TRUE on schema init; look up its id.
admin_row = conn.execute(
"SELECT id FROM user_groups WHERE name = 'Admin'"
).fetchone()
assert admin_row is not None, "Admin group not seeded"
UserGroupMembersRepository(conn).add_member(
user_id="admin", group_id=admin_row[0], source="admin",
)
conn.close()
r = client.post("/auth/token", json={"email": email, "password": "AdminPass1!"})
assert r.status_code == 200, r.text
return {"Authorization": f"Bearer {r.json()['access_token']}"}
def _make_user(client, email="user@example.com"):
from argon2 import PasswordHasher
from src.db import get_system_db
from src.repositories.users import UserRepository
ph = PasswordHasher()
conn = get_system_db()
UserRepository(conn).create(
id="user", email=email, name="User", password_hash=ph.hash("UserPass1!"),
)
conn.close()
r = client.post("/auth/token", json={"email": email, "password": "UserPass1!"})
assert r.status_code == 200, r.text
return {"Authorization": f"Bearer {r.json()['access_token']}"}
def test_admin_get_initial_workspace_not_configured(web_client):
"""GET returns configured:false when no section is in instance.yaml."""
headers = _make_admin(web_client)
r = web_client.get("/api/admin/initial-workspace", headers=headers)
assert r.status_code == 200
assert r.json()["configured"] is False
def test_admin_endpoints_require_admin(web_client):
"""Non-admin user gets 403 on every admin endpoint — all four verbs.
A future refactor that drops `Depends(require_admin)` from one
endpoint must fail here (otherwise we'd silently expose the
write/delete paths to any analyst with a PAT)."""
headers = _make_user(web_client)
cases = [
("GET", "/api/admin/initial-workspace", None),
("POST", "/api/admin/initial-workspace", {"url": "https://example.com/x.git"}),
("DELETE", "/api/admin/initial-workspace", None),
("POST", "/api/admin/initial-workspace/sync", None),
]
for method, path, body in cases:
r = web_client.request(method, path, headers=headers, json=body)
assert r.status_code == 403, f"{method} {path}: {r.status_code} {r.text}"
def test_admin_post_writes_yaml_section(web_client, fake_remote):
"""POST persists `initial_workspace:` to instance.yaml overlay."""
import yaml
from app.secrets import _state_dir
headers = _make_admin(web_client)
r = web_client.post(
"/api/admin/initial-workspace",
headers=headers,
json={"url": fake_remote["url"], "branch": "main"},
)
# file:// URLs are rejected (validator requires https://) — assert
# so we can adjust the test for the relaxed CI case below
assert r.status_code == 422
assert "https" in r.json()["detail"].lower()
def test_admin_post_https_validation(web_client):
"""url must be https://."""
headers = _make_admin(web_client)
r = web_client.post(
"/api/admin/initial-workspace",
headers=headers,
json={"url": "http://example.com/repo.git"},
)
assert r.status_code == 422
def test_admin_post_token_routes_to_env_overlay(web_client, monkeypatch):
"""Token in POST body lands in .env_overlay, env-var name in YAML."""
import yaml
from app.secrets import _state_dir
headers = _make_admin(web_client)
r = web_client.post(
"/api/admin/initial-workspace",
headers=headers,
json={
"url": "https://github.com/example/template.git",
"branch": "main",
"token": "ghp_test_token",
},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["configured"] is True
assert body["url"] == "https://github.com/example/template.git"
assert body["has_token"] is True
overlay = (_state_dir() / ".env_overlay").read_text()
assert "AGNES_INITIAL_WORKSPACE_TOKEN=ghp_test_token" in overlay
instance_yaml = yaml.safe_load((_state_dir() / "instance.yaml").read_text())
section = instance_yaml["initial_workspace"]
assert section["url"] == "https://github.com/example/template.git"
assert section["token_env"] == "AGNES_INITIAL_WORKSPACE_TOKEN"
# Token value never lands in YAML
assert "ghp_test_token" not in yaml.dump(instance_yaml)
def test_admin_post_idempotent(web_client):
"""Two POSTs land one section (overwrite, no duplication)."""
import yaml
from app.secrets import _state_dir
headers = _make_admin(web_client)
for url in ("https://github.com/a/b.git", "https://github.com/c/d.git"):
r = web_client.post(
"/api/admin/initial-workspace",
headers=headers,
json={"url": url, "branch": "main"},
)
assert r.status_code == 200, r.text
instance_yaml = yaml.safe_load((_state_dir() / "instance.yaml").read_text())
section = instance_yaml["initial_workspace"]
assert section["url"] == "https://github.com/c/d.git" # latest wins
def test_admin_delete_removes_section_and_token(web_client):
"""DELETE wipes YAML section + .env_overlay key."""
import yaml
from app.secrets import _state_dir
headers = _make_admin(web_client)
web_client.post(
"/api/admin/initial-workspace",
headers=headers,
json={"url": "https://github.com/a/b.git", "token": "ghp_x"},
)
r = web_client.delete("/api/admin/initial-workspace", headers=headers)
assert r.status_code == 204
instance_yaml = yaml.safe_load((_state_dir() / "instance.yaml").read_text() or "{}") or {}
assert "initial_workspace" not in instance_yaml
overlay = (_state_dir() / ".env_overlay").read_text()
assert "AGNES_INITIAL_WORKSPACE_TOKEN" not in overlay
def test_admin_sync_against_file_url(web_client, fake_remote, monkeypatch):
"""End-to-end: register file:// URL (bypass https check via DB), run sync,
verify last_synced_at + last_commit_sha land in YAML."""
import yaml
from app.api.initial_workspace import _write_section
from app.secrets import _state_dir
# Bypass the https:// validation by patching the section directly —
# the test fake_remote is file:// (no real git server in CI).
_write_section({"url": fake_remote["url"], "branch": "main", "token_env": None})
headers = _make_admin(web_client)
r = web_client.post("/api/admin/initial-workspace/sync", headers=headers)
assert r.status_code == 200, r.text
body = r.json()
assert body["action"] == "sync_ok"
assert body["commit_sha"] == fake_remote["sha"]
assert body["file_count"] >= 2 # CLAUDE.md + .claude/settings.json
instance_yaml = yaml.safe_load((_state_dir() / "instance.yaml").read_text())
section = instance_yaml["initial_workspace"]
assert section["last_commit_sha"] == fake_remote["sha"]
assert section["last_synced_at"] is not None
assert section.get("last_error") is None
def test_analyst_status_unconfigured(web_client):
"""PAT-authed user sees configured:false when no template registered."""
headers = _make_user(web_client)
r = web_client.get("/api/initial-workspace", headers=headers)
assert r.status_code == 200
assert r.json()["configured"] is False
def test_analyst_status_configured_synced(web_client, fake_remote):
"""Analyst sees full metadata + file list when configured + synced."""
from app.api.initial_workspace import _write_section
from src.initial_workspace import sync_template
# Register + sync directly (bypass https:// check)
_write_section({"url": fake_remote["url"], "branch": "main", "token_env": None})
result = sync_template(url=fake_remote["url"], branch="main")
_write_section({
"last_synced_at": "2026-05-13T10:00:00+00:00",
"last_commit_sha": result["commit_sha"],
})
headers = _make_user(web_client)
r = web_client.get("/api/initial-workspace", headers=headers)
assert r.status_code == 200, r.text
body = r.json()
assert body["configured"] is True
assert body["synced"] is True
assert body["template_sha"] == result["commit_sha"]
assert "CLAUDE.md" in body["files"]
def test_analyst_zip_404_when_not_configured(web_client):
"""GET /api/initial-workspace.zip returns 404 when no template."""
headers = _make_user(web_client)
r = web_client.get("/api/initial-workspace.zip", headers=headers)
assert r.status_code == 404
def test_analyst_zip_503_when_not_synced(web_client):
"""503 when configured but never synced."""
from app.api.initial_workspace import _write_section
_write_section({"url": "https://github.com/a/b.git", "branch": "main", "token_env": None})
headers = _make_user(web_client)
r = web_client.get("/api/initial-workspace.zip", headers=headers)
assert r.status_code == 503
def test_analyst_zip_returns_bytes_and_etag(web_client, fake_remote):
"""200 returns zip bytes with ETag = template_sha."""
import io
import zipfile
from app.api.initial_workspace import _write_section
from src.initial_workspace import sync_template
_write_section({"url": fake_remote["url"], "branch": "main", "token_env": None})
result = sync_template(url=fake_remote["url"], branch="main")
_write_section({
"last_synced_at": "2026-05-13T10:00:00+00:00",
"last_commit_sha": result["commit_sha"],
})
headers = _make_user(web_client)
r = web_client.get("/api/initial-workspace.zip", headers=headers)
assert r.status_code == 200, r.text
assert r.headers["content-type"] == "application/zip"
assert r.headers["etag"] == f'"{result["commit_sha"]}"'
names = sorted(zipfile.ZipFile(io.BytesIO(r.content)).namelist())
assert "CLAUDE.md" in names
def test_analyst_zip_writes_fetch_started_audit(web_client, fake_remote):
"""GET .../zip writes a server-side audit row."""
from app.api.initial_workspace import _write_section
from src.db import get_system_db
from src.initial_workspace import sync_template
_write_section({"url": fake_remote["url"], "branch": "main", "token_env": None})
result = sync_template(url=fake_remote["url"], branch="main")
_write_section({
"last_synced_at": "2026-05-13T10:00:00+00:00",
"last_commit_sha": result["commit_sha"],
})
headers = _make_user(web_client)
web_client.get("/api/initial-workspace.zip", headers=headers)
conn = get_system_db()
rows = conn.execute(
"SELECT action, params FROM audit_log WHERE action = 'initial_workspace.fetch_started'"
).fetchall()
conn.close()
assert len(rows) == 1, rows
params = json.loads(rows[0][1])
assert params["template_sha"] == result["commit_sha"]
def test_analyst_applied_writes_audit(web_client):
"""POST /applied writes audit row with mode + counts."""
from src.db import get_system_db
headers = _make_user(web_client)
r = web_client.post(
"/api/initial-workspace/applied",
headers=headers,
json={
"mode": "fresh_install",
"template_sha": "abc123",
"files_overwritten": 0,
"files_created": 5,
},
)
assert r.status_code == 200
conn = get_system_db()
rows = conn.execute(
"SELECT params FROM audit_log WHERE action = 'initial_workspace.applied'"
).fetchall()
conn.close()
assert len(rows) == 1
params = json.loads(rows[0][0])
assert params["mode"] == "fresh_install"
assert params["files_created"] == 5
def test_analyst_applied_rejects_invalid_mode(web_client):
"""POST /applied with garbage mode returns 422."""
headers = _make_user(web_client)
r = web_client.post(
"/api/initial-workspace/applied",
headers=headers,
json={"mode": "garbage"},
)
assert r.status_code == 422