feat(timeline): record a change by hand from the Timeline, a host's page or MCP log_change (#6350)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m20s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped

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:
2026-10-24 21:26:02 -04:00
co-authored by Claude Opus 5.5
parent 2b97c36fd4
commit 329aa15e3d
15 changed files with 703 additions and 38 deletions
+81
View File
@@ -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&amp;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