Background automations had no session, so get_current_profile_id() fell back to admin (1) — wrong for a non-admin's scheduled job. Now the engine declares the automation's owner around handler execution via a contextvar (core/profile_context.py), and get_current_profile_id() consults it only when there's NO web request. So: - a real logged-in request always wins (foreground unchanged), - admin + system automations are profile 1 → resolve to admin exactly as before (the 8 admin-owned auto-sync pipelines behave identically), - only non-admin-owned automations gain their correct identity, deep through the whole call chain (incl. the per-profile client resolvers) — no threading profile_id through dozens of signatures. Reset in a finally so a pooled thread can't leak the override to the next job. Tests: contextvar set/reset/nested; get_current_profile_id honours the override only outside a request (a real session still wins); and end-to-end — the engine runs a non-admin automation as profile 4, an admin one as 1, an explicit trigger profile overrides the owner, and the context resets even when the handler raises. 27 + 4 tests pass. Part 2 (next): point the sync handlers' source-playlist READ at get_spotify_client_for_profile so a non-admin's auto-sync pulls THEIR playlist.
64 lines
2.4 KiB
Python
64 lines
2.4 KiB
Python
"""The engine runs each automation AS its owner profile (per-profile automations).
|
|
|
|
A non-admin's scheduled job must execute with their profile in the background
|
|
context, so get_current_profile_id() / the per-profile clients act as them — not
|
|
admin. Admin/system automations (profile 1) are unchanged. The context must be
|
|
reset after every run so a pooled thread can't leak it.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from core.automation_engine import AutomationEngine
|
|
from core.profile_context import get_background_profile
|
|
|
|
|
|
def _engine(owner_profile_id):
|
|
db = MagicMock()
|
|
db.get_automation.return_value = {
|
|
'id': 1, 'name': 'Auto-Sync', 'enabled': True,
|
|
'action_type': 'sync_playlist', 'action_config': '{}',
|
|
'trigger_type': 'interval_hours', 'trigger_config': '{"hours": 1}',
|
|
'profile_id': owner_profile_id,
|
|
}
|
|
db.update_automation_run = MagicMock(return_value=True)
|
|
eng = AutomationEngine(db)
|
|
eng._running = True
|
|
seen = {}
|
|
eng._action_handlers['sync_playlist'] = {
|
|
'handler': lambda config: seen.update(profile=get_background_profile()) or {'status': 'completed'},
|
|
'guard': None,
|
|
}
|
|
return eng, seen
|
|
|
|
|
|
def test_nonadmin_owned_automation_runs_as_owner():
|
|
eng, seen = _engine(owner_profile_id=4)
|
|
eng.run_automation(1, skip_delay=True)
|
|
assert seen['profile'] == 4 # handler ran AS profile 4
|
|
assert get_background_profile() is None # reset after the run
|
|
|
|
|
|
def test_admin_owned_automation_runs_as_admin():
|
|
eng, seen = _engine(owner_profile_id=1)
|
|
eng.run_automation(1, skip_delay=True)
|
|
assert seen['profile'] == 1 # unchanged for admin/system
|
|
assert get_background_profile() is None
|
|
|
|
|
|
def test_explicit_trigger_profile_overrides_owner():
|
|
# A manual trigger (run_automation(profile_id=...)) wins over the owner.
|
|
eng, seen = _engine(owner_profile_id=4)
|
|
eng.run_automation(1, skip_delay=True, profile_id=9)
|
|
assert seen['profile'] == 9
|
|
|
|
|
|
def test_context_reset_even_when_handler_raises():
|
|
eng, _ = _engine(owner_profile_id=4)
|
|
eng._action_handlers['sync_playlist'] = {
|
|
'handler': lambda config: (_ for _ in ()).throw(RuntimeError('boom')),
|
|
'guard': None,
|
|
}
|
|
eng.run_automation(1, skip_delay=True) # error is caught + stored
|
|
assert get_background_profile() is None # finally reset it
|