mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
205e7818f8
* fix: replace empty except blocks with diagnostic logging
Add log.debug/warning to 7 bare except-pass blocks that silenced
failures in security-relevant or operationally-important paths:
- Channel route lookup, CLI policy evaluation, OIDC JWKS fetch,
prompt policy loading, plan file write, routing override, username
resolution.
Plan write now reports failure to user instead of falsely claiming
"Plan saved."
* fix: replace assert-with-side-effect and narrow BaseException catch
- Convert 4 assert isinstance() to explicit TypeError raises — assertions
are stripped under python -O, removing runtime type checks
- Narrow except BaseException to except Exception in fallback handler —
KeyboardInterrupt/SystemExit should not record as health failures
- Plan write failure now reports error to user instead of "Plan saved"
* fix: wire up toast error type and remove useless conditional
- showToast() now accepts optional type param ("error") with red border
styling — 3 call sites were passing "error" that was silently ignored
- Remove always-true if (q) guard after early-return on empty query
* fix: remove unreachable return None after return self._judge
* fix: parenthesize multi-line string concatenations in dev_parts list
Explicit parens make intentional concatenation unambiguous to static
analysis (CodeQL implicit-string-concatenation-in-list rule).
* fix: remove constant-true filter in test mock — return list directly
* fix: extract side-effecting calls from assert in tests
store.delete() and mgr.close() have side effects that would be
stripped under python -O. Assign to variable first, then assert.
* fix: remove unused local variables in tests
Drop assignments to unused workstream/variable references created
solely for side effects. Use _ for unused tuple unpacking.
* fix: use admin.prompt_policies permission for prompt policy endpoints
All 5 prompt-policy endpoints (list, create, get, update, delete)
were checking admin.policies (the tool-policy permission) instead of
admin.prompt_policies. This caused a mismatch with the admin UI which
gates the tab on admin.prompt_policies — users could see the tab but
get 403, or reach the endpoint but never see the tab.
* fix: use caplog instead of capsys for structlog warning assertion
structlog output goes through the logging system, not stdout/stderr.
* fix: address review — remove dead isinstance, module-level import, unnecessary lambdas
- session.py: remove unreachable isinstance check (has_batch already
validates raw_edits is a list)
- cli.py: move logging import to module level
- test_workstream.py: replace lambda wid: FakeUI(wid) with FakeUI
461 lines
15 KiB
Python
461 lines
15 KiB
Python
"""Tests for edit_file tool — single edit and batch edit modes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.session import ChatSession
|
|
|
|
|
|
@pytest.fixture
|
|
def session(tmp_db, mock_openai_client):
|
|
"""Create a ChatSession wired to a temp database."""
|
|
return ChatSession(
|
|
client=mock_openai_client,
|
|
model="test-model",
|
|
ui=MagicMock(),
|
|
instructions=None,
|
|
temperature=0.5,
|
|
max_tokens=1000,
|
|
tool_timeout=10,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_file(tmp_path):
|
|
"""Create a sample file and return its path."""
|
|
p = tmp_path / "test.py"
|
|
p.write_text("line1\nline2\nline3\nline4\nline5\n")
|
|
return str(p)
|
|
|
|
|
|
def _mark_read(session: ChatSession, path: str) -> None:
|
|
"""Simulate a prior read_file so the edit guard passes."""
|
|
resolved = os.path.realpath(os.path.expanduser(path))
|
|
session._read_files.add(resolved)
|
|
|
|
|
|
# ── Single edit (backward compat) ────────────────────────────────────
|
|
|
|
|
|
class TestSingleEdit:
|
|
def test_basic_replace(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"old_string": "line2",
|
|
"new_string": "replaced",
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
assert result["func_name"] == "edit_file"
|
|
|
|
call_id, msg = session._exec_edit_file(result)
|
|
assert call_id == "c1"
|
|
assert "applied 1 edit" in msg
|
|
with open(sample_file) as f:
|
|
assert f.read() == "line1\nreplaced\nline3\nline4\nline5\n"
|
|
|
|
def test_missing_path(self, session):
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"old_string": "a",
|
|
"new_string": "b",
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "missing path" in result["error"]
|
|
|
|
def test_missing_old_string(self, session, sample_file):
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"new_string": "b",
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "old_string" in result["error"]
|
|
|
|
def test_identical_strings(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"old_string": "line1",
|
|
"new_string": "line1",
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "identical" in result["error"]
|
|
|
|
def test_old_string_not_found(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"old_string": "nonexistent",
|
|
"new_string": "replaced",
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "not found" in result["error"]
|
|
|
|
def test_must_read_first(self, session, sample_file):
|
|
# Don't call _mark_read — should fail
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"old_string": "line1",
|
|
"new_string": "replaced",
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "must read_file" in result["error"]
|
|
|
|
def test_multiple_occurrences_without_near_line(self, session, tmp_path):
|
|
p = tmp_path / "dup.txt"
|
|
p.write_text("foo\nbar\nfoo\n")
|
|
path = str(p)
|
|
_mark_read(session, path)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": path,
|
|
"old_string": "foo",
|
|
"new_string": "baz",
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "found 2 times" in result["error"]
|
|
|
|
def test_near_line_disambiguates(self, session, tmp_path):
|
|
p = tmp_path / "dup.txt"
|
|
p.write_text("foo\nbar\nfoo\n")
|
|
path = str(p)
|
|
_mark_read(session, path)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": path,
|
|
"old_string": "foo",
|
|
"new_string": "baz",
|
|
"near_line": 3,
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
assert "applied 1 edit" in msg
|
|
with open(path) as f:
|
|
assert f.read() == "foo\nbar\nbaz\n"
|
|
|
|
def test_deletion(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"old_string": "line3\n",
|
|
"new_string": "",
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
assert "deletion" in result["preview"]
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
with open(sample_file) as f:
|
|
assert f.read() == "line1\nline2\nline4\nline5\n"
|
|
|
|
|
|
# ── Batch edits ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBatchEdit:
|
|
def test_two_edits_applied_atomically(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"edits": [
|
|
{"old_string": "line1", "new_string": "first"},
|
|
{"old_string": "line5", "new_string": "last"},
|
|
],
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
assert "2 edits" in result["header"]
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
assert "applied 2 edits" in msg
|
|
with open(sample_file) as f:
|
|
assert f.read() == "first\nline2\nline3\nline4\nlast\n"
|
|
|
|
def test_three_edits_middle_of_file(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"edits": [
|
|
{"old_string": "line2", "new_string": "second"},
|
|
{"old_string": "line3", "new_string": "third"},
|
|
{"old_string": "line4", "new_string": "fourth"},
|
|
],
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
assert "applied 3 edits" in msg
|
|
with open(sample_file) as f:
|
|
assert f.read() == "line1\nsecond\nthird\nfourth\nline5\n"
|
|
|
|
def test_overlapping_edits_rejected(self, session, tmp_path):
|
|
p = tmp_path / "overlap.txt"
|
|
p.write_text("abcdefgh\n")
|
|
path = str(p)
|
|
_mark_read(session, path)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": path,
|
|
"edits": [
|
|
{"old_string": "abcdef", "new_string": "XXX"},
|
|
{"old_string": "defgh", "new_string": "YYY"},
|
|
],
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
assert "overlap" in msg.lower()
|
|
# File should be untouched
|
|
with open(path) as f:
|
|
assert f.read() == "abcdefgh\n"
|
|
|
|
def test_batch_edit_not_found(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"edits": [
|
|
{"old_string": "line1", "new_string": "first"},
|
|
{"old_string": "nonexistent", "new_string": "oops"},
|
|
],
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "edits[1]" in result["error"]
|
|
assert "not found" in result["error"]
|
|
|
|
def test_batch_edit_missing_old_string(self, session, sample_file):
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"edits": [
|
|
{"old_string": "line1", "new_string": "first"},
|
|
{"new_string": "oops"},
|
|
],
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "edits[1]" in result["error"]
|
|
assert "old_string" in result["error"]
|
|
|
|
def test_batch_edit_identical_strings(self, session, sample_file):
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"edits": [
|
|
{"old_string": "line1", "new_string": "line1"},
|
|
],
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "identical" in result["error"]
|
|
|
|
def test_batch_with_near_line(self, session, tmp_path):
|
|
p = tmp_path / "dup.txt"
|
|
p.write_text("foo\nbar\nfoo\nbaz\n")
|
|
path = str(p)
|
|
_mark_read(session, path)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": path,
|
|
"edits": [
|
|
{"old_string": "foo", "new_string": "first_foo", "near_line": 1},
|
|
{"old_string": "foo", "new_string": "second_foo", "near_line": 3},
|
|
],
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
assert "applied 2 edits" in msg
|
|
with open(path) as f:
|
|
assert f.read() == "first_foo\nbar\nsecond_foo\nbaz\n"
|
|
|
|
def test_batch_with_deletion(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"edits": [
|
|
{"old_string": "line2\n", "new_string": ""},
|
|
{"old_string": "line4\n", "new_string": ""},
|
|
],
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
with open(sample_file) as f:
|
|
assert f.read() == "line1\nline3\nline5\n"
|
|
|
|
def test_single_item_edits_array(self, session, sample_file):
|
|
"""An edits array with one item should work like a single edit."""
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"edits": [
|
|
{"old_string": "line3", "new_string": "middle"},
|
|
],
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
# Single edit — no "(N edits)" count in header
|
|
assert "edits)" not in result["header"]
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
assert "applied 1 edit" in msg
|
|
with open(sample_file) as f:
|
|
assert f.read() == "line1\nline2\nmiddle\nline4\nline5\n"
|
|
|
|
|
|
# ── Mutual exclusivity ──────────────────────────────────────────────
|
|
|
|
|
|
class TestMutualExclusivity:
|
|
def test_both_single_and_batch_rejected(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"old_string": "line1",
|
|
"new_string": "replaced",
|
|
"edits": [
|
|
{"old_string": "line2", "new_string": "also_replaced"},
|
|
],
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "not both" in result["error"]
|
|
|
|
def test_neither_single_nor_batch(self, session, sample_file):
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
},
|
|
)
|
|
assert result.get("error")
|
|
assert "old_string" in result["error"]
|
|
|
|
def test_empty_edits_array_falls_through_to_single(self, session, sample_file):
|
|
"""An empty edits array should be treated as no batch."""
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"edits": [],
|
|
},
|
|
)
|
|
# Falls through to single-edit path, which requires old_string
|
|
assert result.get("error")
|
|
assert "old_string" in result["error"]
|
|
|
|
|
|
# ── TOCTOU edge cases ───────────────────────────────────────────────
|
|
|
|
|
|
class TestExecEdgeCases:
|
|
def test_file_changed_between_prepare_and_exec(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"old_string": "line2",
|
|
"new_string": "replaced",
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
|
|
# Modify the file after prepare
|
|
with open(sample_file, "w") as f:
|
|
f.write("completely different content\n")
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
assert "no longer found" in msg
|
|
|
|
def test_file_deleted_between_prepare_and_exec(self, session, sample_file):
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"old_string": "line2",
|
|
"new_string": "replaced",
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
|
|
os.unlink(sample_file)
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
assert "Error" in msg
|
|
|
|
def test_batch_file_changed_partial_match(self, session, sample_file):
|
|
"""If file changes so one edit fails, none should be applied."""
|
|
_mark_read(session, sample_file)
|
|
result = session._prepare_edit_file(
|
|
"c1",
|
|
{
|
|
"path": sample_file,
|
|
"edits": [
|
|
{"old_string": "line1", "new_string": "first"},
|
|
{"old_string": "line5", "new_string": "last"},
|
|
],
|
|
},
|
|
)
|
|
assert result["needs_approval"]
|
|
|
|
# Remove line5 between prepare and exec
|
|
with open(sample_file, "w") as f:
|
|
f.write("line1\nline2\nline3\nline4\n")
|
|
|
|
_, msg = session._exec_edit_file(result)
|
|
assert "no longer found" in msg
|
|
# line1 should NOT have been edited (atomic failure)
|
|
with open(sample_file) as f:
|
|
assert "line1" in f.read()
|