mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-20 23:22:21 +02:00
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""manage_tasks mutations must fail closed on owner-less / cross-owner tasks.
|
|
|
|
The edit/delete/pause/run actions of ``do_manage_tasks`` previously gated with
|
|
``if owner and task.owner and task.owner != owner``. The middle term made the
|
|
check a no-op whenever the task had no owner — the state a scheduled task is in
|
|
when it was created in no-login mode (or via the localhost middleware bypass)
|
|
before the periodic legacy-owner sweep reassigns it to the admin user. So any
|
|
authenticated user's agent could edit, delete, pause, or *run* another tenant's
|
|
owner-less task. The sibling ``list`` action already scopes with an exact
|
|
``ScheduledTask.owner == owner`` filter, so the mutators were strictly more
|
|
permissive than the reader.
|
|
"""
|
|
|
|
import json
|
|
import tempfile
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from tests.helpers.import_state import clear_fake_database_modules
|
|
|
|
clear_fake_database_modules()
|
|
|
|
import core.database as cdb
|
|
from core.database import ScheduledTask
|
|
from src.tools.system import do_manage_tasks
|
|
|
|
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
_ENGINE = create_engine(
|
|
f"sqlite:///{_TMPDB.name}",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=NullPool,
|
|
)
|
|
cdb.Base.metadata.create_all(_ENGINE)
|
|
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
|
# do_manage_tasks does `from core.database import SessionLocal` at call time,
|
|
# so patching the module attribute is enough to point it at the temp DB.
|
|
cdb.SessionLocal = _TS
|
|
|
|
|
|
def _seed(task_id, owner, *, name=None):
|
|
db = _TS()
|
|
try:
|
|
db.add(ScheduledTask(
|
|
id=task_id, owner=owner, name=name or task_id, prompt="original",
|
|
task_type="llm", trigger_type="webhook", status="active",
|
|
output_target="session",
|
|
))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _get(task_id):
|
|
db = _TS()
|
|
try:
|
|
return db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _delete(task_id):
|
|
db = _TS()
|
|
try:
|
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
|
if task:
|
|
db.delete(task)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_denied_on_ownerless_task_for_authenticated_user():
|
|
_seed("ownerless-edit", None)
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "edit", "task_id": "ownerless-edit", "prompt": "pwned"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
|
assert _get("ownerless-edit").prompt == "original"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_denied_on_ownerless_task_for_authenticated_user():
|
|
_seed("ownerless-del", None)
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "delete", "task_id": "ownerless-del"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
|
assert _get("ownerless-del") is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_denied_on_ownerless_task_for_authenticated_user():
|
|
_seed("ownerless-pause", None)
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "pause", "task_id": "ownerless-pause"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
|
assert _get("ownerless-pause").status == "active"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_denied_on_ownerless_task_for_authenticated_user():
|
|
_seed("ownerless-run", None)
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "run", "task_id": "ownerless-run"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_denied_on_other_owners_task():
|
|
_seed("bob-task", "bob")
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "edit", "task_id": "bob-task", "prompt": "pwned"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 1 and out["error"] == "Access denied"
|
|
assert _get("bob-task").prompt == "original"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_allowed_for_matching_owner():
|
|
_seed("alice-task", "alice")
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "edit", "task_id": "alice-task", "prompt": "updated"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 0
|
|
assert _get("alice-task").prompt == "updated"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_allowed_in_no_login_mode():
|
|
# owner is None when auth is disabled — single-user mode keeps full access
|
|
# to shared (owner-less) tasks, exactly as `list` returns them unfiltered.
|
|
_seed("shared-task", None)
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "edit", "task_id": "shared-task", "prompt": "updated"}),
|
|
owner=None,
|
|
)
|
|
assert out["exit_code"] == 0
|
|
assert _get("shared-task").prompt == "updated"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mutation_resolves_exact_name_when_model_puts_name_in_task_id():
|
|
_seed("task-uuid-for-name-fallback", "alice", name="Daily fixture by name")
|
|
try:
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "pause", "task_id": "Daily fixture by name"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 0
|
|
assert _get("task-uuid-for-name-fallback").status == "paused"
|
|
finally:
|
|
_delete("task-uuid-for-name-fallback")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exact_name_fallback_rejects_ambiguous_matches():
|
|
_seed("ambiguous-task-a", "alice", name="Duplicate task name")
|
|
_seed("ambiguous-task-b", "alice", name="Duplicate task name")
|
|
try:
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "delete", "task_id": "Duplicate task name"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 1
|
|
assert out["error"] == "Task name 'Duplicate task name' matched 2 tasks; use task_id"
|
|
assert _get("ambiguous-task-a") is not None
|
|
assert _get("ambiguous-task-b") is not None
|
|
finally:
|
|
_delete("ambiguous-task-a")
|
|
_delete("ambiguous-task-b")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_filters_by_exact_name_instead_of_dumping_all_tasks():
|
|
_seed("list-filter-target", "alice", name="Needle task")
|
|
_seed("list-filter-other", "alice", name="Other task")
|
|
try:
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "list", "name": "Needle task"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 0
|
|
assert "Needle task" in out["response"]
|
|
assert "list-filter-target" in out["response"]
|
|
assert "Other task" not in out["response"]
|
|
assert "list-filter-other" not in out["response"]
|
|
finally:
|
|
_delete("list-filter-target")
|
|
_delete("list-filter-other")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_filters_by_pattern_alias():
|
|
_seed("pattern-filter-target", "alice", name="Pattern needle task")
|
|
_seed("pattern-filter-other", "alice", name="Unrelated task")
|
|
try:
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "list", "pattern": "needle"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 0
|
|
assert "Pattern needle task" in out["response"]
|
|
assert "pattern-filter-target" in out["response"]
|
|
assert "Unrelated task" not in out["response"]
|
|
assert "pattern-filter-other" not in out["response"]
|
|
finally:
|
|
_delete("pattern-filter-target")
|
|
_delete("pattern-filter-other")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_filters_by_prompt_alias():
|
|
_seed("prompt-filter-target", "alice", name="Prompt needle task")
|
|
_seed("prompt-filter-other", "alice", name="Unrelated task")
|
|
try:
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "list", "prompt": "needle"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 0
|
|
assert "Prompt needle task" in out["response"]
|
|
assert "prompt-filter-target" in out["response"]
|
|
assert "Unrelated task" not in out["response"]
|
|
assert "prompt-filter-other" not in out["response"]
|
|
finally:
|
|
_delete("prompt-filter-target")
|
|
_delete("prompt-filter-other")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_off_create_persists_utc_date_and_next_run():
|
|
result = await do_manage_tasks(json.dumps({
|
|
'action': 'create', 'name': 'dated fixture', 'prompt': 'synthetic',
|
|
'schedule': 'once', 'scheduled_date': '2099-01-01T09:00:00+09:00',
|
|
}), owner='alice')
|
|
assert result['exit_code'] == 0
|
|
try:
|
|
row = _get(result['task_id'])
|
|
assert row.scheduled_date == datetime(2099, 1, 1)
|
|
assert row.next_run == row.scheduled_date
|
|
finally:
|
|
_delete(result['task_id'])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_off_edit_pause_resume_keeps_exact_schedule():
|
|
result = await do_manage_tasks(json.dumps({
|
|
'action': 'create', 'name': 'dated edit fixture', 'prompt': 'synthetic',
|
|
'schedule': 'once', 'scheduled_date': '2099-01-01T00:00:00Z',
|
|
}), owner='alice')
|
|
task_id = result['task_id']
|
|
try:
|
|
edited = await do_manage_tasks(json.dumps({
|
|
'action': 'edit', 'task_id': task_id, 'scheduled_date': '2099-01-02T09:00:00+09:00',
|
|
}), owner='alice')
|
|
assert edited['exit_code'] == 0
|
|
assert _get(task_id).scheduled_date == datetime(2099, 1, 2)
|
|
assert _get(task_id).next_run == datetime(2099, 1, 2)
|
|
for action in ['pause', 'resume']:
|
|
out = await do_manage_tasks(json.dumps({'action': action, 'task_id': task_id}), owner='alice')
|
|
assert out['exit_code'] == 0
|
|
assert _get(task_id).status == 'active'
|
|
assert _get(task_id).next_run == datetime(2099, 1, 2)
|
|
invalid = await do_manage_tasks(json.dumps({
|
|
'action': 'edit', 'task_id': task_id, 'name': 'must not stick', 'scheduled_date': 'bad-date',
|
|
}), owner='alice')
|
|
assert invalid['exit_code'] == 1
|
|
assert _get(task_id).name == 'dated edit fixture'
|
|
assert _get(task_id).scheduled_date == datetime(2099, 1, 2)
|
|
finally:
|
|
_delete(task_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('value', [None, '', 'bad-date', '2000-01-01T00:00:00Z'])
|
|
async def test_invalid_one_off_date_never_creates_an_inert_active_task(value):
|
|
out = await do_manage_tasks(json.dumps({
|
|
'action': 'create', 'name': 'invalid dated fixture', 'prompt': 'synthetic',
|
|
'schedule': 'once', 'scheduled_date': value,
|
|
}), owner='alice')
|
|
assert out['exit_code'] == 1
|
|
with _TS() as db:
|
|
assert db.query(ScheduledTask).filter(ScheduledTask.name == 'invalid dated fixture').count() == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_search_finds_instruction_text_without_other_owner_results():
|
|
_seed('prompt-only-alice', 'alice', name='Reading fixture')
|
|
_seed('prompt-only-bob', 'bob', name='Other private fixture')
|
|
try:
|
|
for task_id, owner in [('prompt-only-alice', 'alice'), ('prompt-only-bob', 'bob')]:
|
|
out = await do_manage_tasks(json.dumps({
|
|
'action': 'edit', 'task_id': task_id, 'prompt': 'Report the lavender inventory',
|
|
}), owner=owner)
|
|
assert out['exit_code'] == 0
|
|
listed = await do_manage_tasks(json.dumps({'action': 'list', 'query': 'lavender'}), owner='alice')
|
|
assert listed['exit_code'] == 0
|
|
assert 'Reading fixture' in listed['response']
|
|
assert 'Other private fixture' not in listed['response']
|
|
finally:
|
|
_delete('prompt-only-alice')
|
|
_delete('prompt-only-bob')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_filters_by_match_alias():
|
|
_seed("match-filter-target", "alice", name="Match needle task")
|
|
_seed("match-filter-other", "alice", name="Unrelated task")
|
|
try:
|
|
out = await do_manage_tasks(
|
|
json.dumps({"action": "list", "match": "needle"}),
|
|
owner="alice",
|
|
)
|
|
assert out["exit_code"] == 0
|
|
assert "Match needle task" in out["response"]
|
|
assert "match-filter-target" in out["response"]
|
|
assert "Unrelated task" not in out["response"]
|
|
assert "match-filter-other" not in out["response"]
|
|
finally:
|
|
_delete("match-filter-target")
|
|
_delete("match-filter-other")
|