feat(timeline): record a change by hand from the Timeline, a host's page or MCP log_change (#6350)
A change Steward can't see happen (a config edited, a disk swapped) is recorded by whoever made it: what was done, its area, when (in the reader's own zone), the hosts, what it was done to and a detail. Its author or an admin edits or deletes it; recording, editing and deleting are audited and those audit rows stay off the timeline. log_change records one as "<user> via MCP", editable by the token's user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -268,3 +268,84 @@ async def test_settings_lists_each_type_under_its_kind(app):
|
||||
assert 'name="type_shown_ansible.check" aria-label="Show Playbook dry run">' in page
|
||||
assert '<option value="monitoring" selected>Monitoring</option>' in page
|
||||
assert 'aria-current="page">Timeline</a>' in page
|
||||
|
||||
|
||||
# ── Changes recorded by hand ────────────────────────────────────────────────
|
||||
|
||||
def test_a_recorded_change_is_its_own_type_and_its_audit_rows_stay_off():
|
||||
t = catalog.TYPES["change.recorded"]
|
||||
assert (t.kind, t.shown) == ("change", True)
|
||||
for action in ("timeline.change_recorded", "timeline.change_edited", "timeline.change_deleted"):
|
||||
assert catalog.audit_type(action) is None
|
||||
|
||||
|
||||
async def test_a_change_is_checked_before_it_is_recorded():
|
||||
from datetime import timedelta
|
||||
from steward.timeline import changes
|
||||
cfg = _cfg()
|
||||
f = await changes.fields(None, cfg, summary=" Swapped the PSU ", area="hardware", subject=" psu ",
|
||||
note="", now=NOW)
|
||||
assert f == {"summary": "Swapped the PSU", "area": "hardware", "hosts": [], "subject": "psu",
|
||||
"note": None, "at": NOW}
|
||||
for kw, said in ((dict(summary=" ", area="hardware"), "Say what was done"),
|
||||
(dict(summary="x" * 301, area="hardware"), "300 characters"),
|
||||
(dict(summary="x", area="plumbing"), "Pick an area: Service, Host OS"),
|
||||
(dict(summary="x", area="hardware", note="n" * 4001), "4000 characters"),
|
||||
(dict(summary="x", area="hardware", at=NOW + timedelta(minutes=10)), "has passed")):
|
||||
with pytest.raises(ValueError, match=said):
|
||||
await changes.fields(None, cfg, now=NOW, **kw)
|
||||
# A clock a little ahead is allowed for.
|
||||
assert (await changes.fields(None, cfg, summary="x", area="hardware", at=NOW + timedelta(minutes=2),
|
||||
now=NOW))["at"] == NOW + timedelta(minutes=2)
|
||||
|
||||
|
||||
def test_only_its_author_or_an_admin_edits_a_recorded_change():
|
||||
from steward.timeline import changes
|
||||
mine = _entry(type_="change.recorded", detail={"author_id": "u1"})
|
||||
assert changes.can_edit(mine, "u1", "operator")
|
||||
assert not changes.can_edit(mine, "u2", "operator")
|
||||
assert changes.can_edit(mine, "u2", "admin")
|
||||
assert not changes.can_edit(mine, None, "admin") # a share link
|
||||
assert not changes.can_edit(_entry(), "u1", "admin") # only recorded changes
|
||||
|
||||
|
||||
async def test_a_recorded_change_the_reader_may_edit_has_an_edit_link(app):
|
||||
mine = _item(_entry(id="c1", type_="change.recorded", area="storage", hosts=["web-1"], subject=None,
|
||||
summary="Moved Postgres to the SSD", detail={"note": "old disk\nis spare",
|
||||
"author_id": "u1"}),
|
||||
editable=True)
|
||||
theirs = _item(_entry(id="c2", type_="change.recorded", summary="Theirs", detail={"author_id": "u2"}),
|
||||
editable=False, opens_group=False)
|
||||
async with app.test_request_context("/timeline/"):
|
||||
rows = await render_template("timeline/_rows.html", items=[mine, theirs], last_group="Today",
|
||||
after=None, keep={"host": "web-1"}, back="/timeline/?host=web-1")
|
||||
assert '<a href="/timeline/changes/c1/edit?back=/timeline/%3Fhost%3Dweb-1">Edit</a>' in rows
|
||||
assert "/timeline/changes/c2/edit" not in rows
|
||||
assert "Change · " in rows and "old disk\nis spare" in rows
|
||||
|
||||
|
||||
async def test_an_operator_records_a_change_from_a_host_s_card(app):
|
||||
host = NS(id="h1", name="web-1")
|
||||
async with app.test_request_context("/timeline/host/h1"):
|
||||
from quart import session
|
||||
session["user_role"] = "operator"
|
||||
card = await render_template("timeline/_host_card.html", host=host, items=[])
|
||||
session["user_role"] = "viewer"
|
||||
viewer = await render_template("timeline/_host_card.html", host=host, items=[])
|
||||
assert 'href="/timeline/changes/new?host=web-1&back=/hosts/h1">Record a change</a>' in card
|
||||
assert "Record a change" not in viewer
|
||||
|
||||
|
||||
async def test_the_change_form_names_its_fields_and_offers_delete_only_when_editing(app):
|
||||
values = {"summary": "", "area": "", "hosts": ["web-1"], "subject": "", "note": "", "at": "2026-10-24T08:00"}
|
||||
entry = NS(id="c1", actor="bryan", summary="Swapped the PSU")
|
||||
async with app.test_request_context("/timeline/changes/new"):
|
||||
new = await render_template("timeline/change_form.html", values=values, entry=None, back="/hosts/h1",
|
||||
error=None, hosts=["db-1", "web-1"], areas=AREAS)
|
||||
edit = await render_template("timeline/change_form.html", values=values, entry=entry,
|
||||
back="/timeline/", error="Say what was done.", hosts=["web-1"], areas=AREAS)
|
||||
assert 'action="/timeline/changes"' in new and 'value="2026-10-24T08:00"' in new
|
||||
assert 'value="web-1" checked' in new and 'value="db-1">' in new
|
||||
assert '<a href="/hosts/h1" class="btn btn-ghost">Cancel</a>' in new and 'id="chg-delete"' not in new
|
||||
assert 'action="/timeline/changes/c1"' in edit and 'action="/timeline/changes/c1/delete"' in edit
|
||||
assert "Say what was done." in edit
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Integration: a change recorded by hand (#6350), from the page and through
|
||||
MCP log_change. The form reads its time in the reader's own zone; only the
|
||||
author or an admin edits or deletes one; each step is audited and the audit
|
||||
rows stay off the timeline. Requires STEWARD_DATABASE_URL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
if not os.environ.get("STEWARD_DATABASE_URL"):
|
||||
pytest.skip("needs Postgres")
|
||||
from steward.app import create_app
|
||||
return create_app(testing=False)
|
||||
|
||||
|
||||
async def _user(app, name: str, role: str, tz: str | None = None):
|
||||
from steward.models.users import User, UserRole
|
||||
uid = str(uuid.uuid4())
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(User(id=uid, username=name, email=f"{name}@test", password_hash="x",
|
||||
role=UserRole(role), is_active=True))
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token", login_at=time.time(),
|
||||
**({"tz": tz} if tz else {}))
|
||||
return client, uid
|
||||
|
||||
|
||||
async def _host(app, name: str):
|
||||
from steward.models.hosts import Host
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
host = Host(name=name, address="10.8.8.8")
|
||||
s.add(host)
|
||||
return host
|
||||
|
||||
|
||||
async def _changes(app, host: str):
|
||||
from sqlalchemy import select
|
||||
from steward.models.timeline import TimelineEntry
|
||||
async with app.db_sessionmaker() as s:
|
||||
return (await s.execute(select(TimelineEntry).where(TimelineEntry.hosts.contains([host]))
|
||||
.order_by(TimelineEntry.at))).scalars().all()
|
||||
|
||||
|
||||
async def _audited(app, entry_id: str) -> list[str]:
|
||||
from sqlalchemy import select
|
||||
from steward.models.audit import AuditEvent
|
||||
async with app.db_sessionmaker() as s:
|
||||
return list((await s.execute(select(AuditEvent.action).where(AuditEvent.entity_id == entry_id)
|
||||
.order_by(AuditEvent.timestamp))).scalars())
|
||||
|
||||
|
||||
def test_a_change_recorded_on_the_page_is_edited_by_its_author_and_deleted_by_an_admin(app):
|
||||
async def _go():
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
name = f"tl-ch-{tag}"
|
||||
host = await _host(app, name)
|
||||
author, author_id = await _user(app, f"ch-a-{tag}", "operator", tz="America/New_York")
|
||||
other, _ = await _user(app, f"ch-o-{tag}", "operator")
|
||||
admin, _ = await _user(app, f"ch-ad-{tag}", "admin")
|
||||
viewer, _ = await _user(app, f"ch-v-{tag}", "viewer")
|
||||
form = {"csrf_token": "test-token", "summary": "Moved Postgres data to the new SSD", "area": "storage",
|
||||
"hosts": name, "subject": "postgres", "note": "Old disk kept as a spare.",
|
||||
"at": "2026-10-20T08:00", "back": f"/hosts/{host.id}"}
|
||||
|
||||
# The form opens from the host's card with the host ticked; a viewer can't open it.
|
||||
page = await (await author.get(f"/timeline/changes/new?host={name}&back=/hosts/{host.id}")).get_data(as_text=True)
|
||||
assert f'value="{name}" checked' in page and f'value="/hosts/{host.id}"' in page
|
||||
assert (await viewer.get("/timeline/changes/new")).status_code == 403
|
||||
|
||||
# Refused, with the form kept: an unknown host writes nothing.
|
||||
bad = await author.post("/timeline/changes", form={**form, "hosts": "no-such-host"})
|
||||
assert bad.status_code == 400
|
||||
text = await bad.get_data(as_text=True)
|
||||
assert "No host is named no-such-host" in text and "Moved Postgres data" in text
|
||||
|
||||
res = await author.post("/timeline/changes", form=form)
|
||||
assert res.status_code == 302 and res.headers["Location"] == f"/hosts/{host.id}"
|
||||
[entry] = await _changes(app, name)
|
||||
assert (entry.type, entry.area, entry.subject, entry.actor) == (
|
||||
"change.recorded", "storage", "postgres", f"ch-a-{tag}")
|
||||
assert entry.at == datetime(2026, 10, 20, 12, 0, tzinfo=timezone.utc) # 08:00 in New York
|
||||
assert entry.detail == {"note": "Old disk kept as a spare.", "author_id": author_id}
|
||||
|
||||
# Its author sees Edit on the timeline; another operator does not, and can't edit it.
|
||||
mine = await (await author.get(f"/timeline/?host={name}")).get_data(as_text=True)
|
||||
assert f"/timeline/changes/{entry.id}/edit" in mine
|
||||
theirs = await (await other.get(f"/timeline/?host={name}")).get_data(as_text=True)
|
||||
assert f"/timeline/changes/{entry.id}/edit" not in theirs
|
||||
assert (await other.get(f"/timeline/changes/{entry.id}/edit")).status_code == 403
|
||||
assert (await other.post(f"/timeline/changes/{entry.id}", form=form)).status_code == 403
|
||||
|
||||
edit = await (await author.get(f"/timeline/changes/{entry.id}/edit")).get_data(as_text=True)
|
||||
assert 'value="2026-10-20T08:00"' in edit # shown in their zone
|
||||
await author.post(f"/timeline/changes/{entry.id}",
|
||||
form={**form, "summary": "Moved Postgres data to the SSD", "area": "hardware"})
|
||||
[entry] = await _changes(app, name)
|
||||
assert (entry.summary, entry.area) == ("Moved Postgres data to the SSD", "hardware")
|
||||
assert entry.detail["author_id"] == author_id # still theirs
|
||||
|
||||
res = await admin.post(f"/timeline/changes/{entry.id}/delete",
|
||||
form={"csrf_token": "test-token", "back": "/timeline/"})
|
||||
assert res.status_code == 302 and await _changes(app, name) == []
|
||||
assert await _audited(app, entry.id) == [
|
||||
"timeline.change_recorded", "timeline.change_edited", "timeline.change_deleted"]
|
||||
# The audit rows are not on the timeline: the change was, as itself.
|
||||
from sqlalchemy import select
|
||||
from steward.models.timeline import TimelineEntry
|
||||
async with app.db_sessionmaker() as s:
|
||||
leaked = (await s.execute(select(TimelineEntry).where(
|
||||
TimelineEntry.summary.contains("Moved Postgres data"),
|
||||
TimelineEntry.actor.in_([f"ch-a-{tag}", f"ch-ad-{tag}"])))).scalars().all()
|
||||
assert leaked == []
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
def test_log_change_records_a_change_through_mcp(app):
|
||||
from steward.core.api_tokens import Caller
|
||||
from steward.mcp import _context
|
||||
from steward.mcp.tools.timeline import get_timeline, log_change
|
||||
from steward.models.users import UserRole
|
||||
|
||||
async def _go():
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
name = f"tl-mc-{tag}"
|
||||
await _host(app, name)
|
||||
_, uid = await _user(app, f"mc-{tag}", "operator")
|
||||
_context._app_ctx.set(app)
|
||||
_context._caller_ctx.set(Caller(user_id=uid, username=f"mc-{tag}", role=UserRole.operator, token_id=7))
|
||||
|
||||
when = (datetime.now(timezone.utc) - timedelta(days=1)).replace(microsecond=0)
|
||||
out = await log_change("Raised the swap to 8 GiB", "Host OS", hosts=name, subject="/swapfile",
|
||||
detail="It ran out during the backup.",
|
||||
at=when.isoformat().replace("+00:00", "Z"))
|
||||
assert out["type"] == "change.recorded" and out["kind"] == "change" and out["area"] == "host_os"
|
||||
assert out["by"] == f"mc-{tag} via MCP" and out["hosts"] == [name]
|
||||
assert out["at"] == when.isoformat().replace("+00:00", "Z") and out["note"] == "It ran out during the backup."
|
||||
[entry] = await _changes(app, name)
|
||||
assert entry.detail["author_id"] == uid # the token's user can edit it
|
||||
assert await _audited(app, entry.id) == ["timeline.change_recorded"]
|
||||
seen = await get_timeline(host=name, range="30d")
|
||||
assert [e["entry_id"] for e in seen["entries"]] == [entry.id]
|
||||
|
||||
with pytest.raises(ValueError, match="list_hosts"):
|
||||
await log_change("x", "storage", hosts=["nope"])
|
||||
with pytest.raises(ValueError, match="area 'plumbing'"):
|
||||
await log_change("x", "plumbing")
|
||||
with pytest.raises(ValueError, match="ISO-8601"):
|
||||
await log_change("x", "storage", at="yesterday")
|
||||
|
||||
asyncio.run(_go())
|
||||
@@ -152,3 +152,11 @@ def test_a_quick_restart_is_shown_though_it_never_broke_the_minute():
|
||||
[ev] = _events.gaps_in([NOW, slow], NOW, NOW + timedelta(hours=1), [(slow, 300.0)])
|
||||
assert "restarted" in ev["what"]
|
||||
assert _events.gaps_in(ticks, NOW + timedelta(minutes=2), NOW + timedelta(hours=1), [(restart, 35.0)]) == []
|
||||
|
||||
|
||||
def test_the_timeline_tools_are_registered_with_their_roles(app):
|
||||
mcp = app.mcp_server
|
||||
for name, role, read_only in (("get_timeline", UserRole.viewer, True),
|
||||
("log_change", UserRole.operator, False)):
|
||||
assert mcp.tool_roles[name] is role, name
|
||||
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is read_only, name
|
||||
|
||||
Reference in New Issue
Block a user