"""Security and real PostgreSQL remote tests in a disposable isolated schema. Run integration cases with BMC_REMOTE_DB_TESTS=1; only localhost is accepted. No production schema, customer record or existing time entry is modified. """ import asyncio from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor from datetime import datetime, timezone, timedelta from pathlib import Path from uuid import uuid4 from urllib.parse import urlsplit import pytest from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient from psycopg2.pool import ThreadedConnectionPool import psycopg2 from app.core.config import settings from app.core import database from app.core.auth_dependencies import get_current_user from app.core.auth_service import AuthService from app.modules.remote import security, service from app.modules.remote.router import router, views, Command def test_code_preserves_leading_zeroes(monkeypatch): monkeypatch.setattr(security.secrets, 'randbelow', lambda maximum: 42) assert security.new_code() == '000000000042' def test_day_is_copenhagen_calendar_day(): assert str(security.today(datetime(2026, 10, 13, 22, 30, tzinfo=timezone.utc))) == '2026-10-14' assert str(security.today(datetime(2026, 1, 13, 22, 30, tzinfo=timezone.utc))) == '2026-01-13' @pytest.mark.parametrize('seconds,status', [(3601, 'normal'), (3600, 'warning'), (0, 'warning'), (-1, 'overdue')]) def test_deadline_thresholds(seconds, status): now = datetime(2026, 10, 13, 12, tzinfo=timezone.utc) row = service.case_display({'deadline': now + timedelta(seconds=seconds)}, now) assert row['deadline_state'] == status def test_naive_deadline_uses_danish_timezone(): row = service.case_display({'deadline': datetime(2026, 10, 13, 14)}, datetime(2026, 10, 13, 12, tzinfo=timezone.utc)) assert row['deadline_seconds'] == 0 assert service.case_display({'deadline': None})['deadline_state'] == 'none' @pytest.mark.parametrize("scheme", ["http", "https"]) def test_flag_origin_and_csrf(monkeypatch, scheme): app = FastAPI(); app.include_router(router) client = TestClient(app, base_url=f'{scheme}://hub.test') monkeypatch.setattr(settings, 'REMOTE_ENABLED', False) assert client.get('/api/v1/remote/bootstrap').status_code == 404 monkeypatch.setattr(settings, 'REMOTE_ENABLED', True) monkeypatch.setattr(settings, 'REMOTE_LOOKUP_KEY', '') assert client.get('/api/v1/remote/bootstrap').status_code == 503 monkeypatch.setattr(settings, 'REMOTE_LOOKUP_KEY', 'test-key-only-' * 4) response = client.get('/api/v1/remote/bootstrap') assert response.status_code == 200 assert ('Secure' in response.headers['set-cookie']) == (scheme == 'https') assert 'HttpOnly' in response.headers['set-cookie'] assert client.post('/api/v1/remote/connect', json={'code': '0'*12, 'pin': '1234'}).status_code == 403 assert client.post('/api/v1/remote/connect', headers={'Origin': 'https://other.test'}, json={'pin': '1234'}).status_code == 403 def test_remote_token_is_not_hub_jwt(): assert AuthService.verify_token('remote-device-random-token') is None @pytest.fixture def db(monkeypatch): import os if os.environ.get('BMC_REMOTE_DB_TESTS') != '1': pytest.skip('Set BMC_REMOTE_DB_TESTS=1 for isolated local PostgreSQL integration tests') assert urlsplit(settings.DATABASE_URL).hostname in {'localhost', '127.0.0.1', '::1'} schema = 'remote_test_' + uuid4().hex root = psycopg2.connect(settings.DATABASE_URL) root.autocommit = True with root.cursor() as cur: cur.execute('CREATE SCHEMA ' + schema) pool = ThreadedConnectionPool(1, 12, dsn=settings.DATABASE_URL, options='-c search_path=' + schema) monkeypatch.setattr(database, 'connection_pool', pool) monkeypatch.setattr(settings, 'REMOTE_ENABLED', True) monkeypatch.setattr(settings, 'REMOTE_LOOKUP_KEY', 'isolated-test-key-only-' * 3) monkeypatch.setattr(settings, 'WORKFLOWS_ENABLED', False) permissions = {1: ['cases.view'], 2: ['cases.view']} monkeypatch.setattr(AuthService, 'get_user_permissions', lambda uid: permissions.get(uid, [])) monkeypatch.setattr(service, 'get_user_messages_summary', lambda uid: {'count': 0, 'list': []}) try: with database.atomic_transaction(): database.execute_query('''CREATE TABLE users(user_id INTEGER PRIMARY KEY,username TEXT,full_name TEXT,email TEXT,is_2fa_enabled BOOLEAN DEFAULT FALSE,is_superadmin BOOLEAN DEFAULT FALSE,is_active BOOLEAN DEFAULT TRUE); CREATE TABLE sessions(user_id INTEGER,token_jti TEXT,expires_at TIMESTAMP,revoked BOOLEAN DEFAULT FALSE); CREATE TABLE customers(id INTEGER PRIMARY KEY,name TEXT); CREATE TABLE sag_sager(id INTEGER PRIMARY KEY,titel TEXT,status TEXT,deadline TIMESTAMP,customer_id INTEGER,ansvarlig_bruger_id INTEGER,deleted_at TIMESTAMP,created_at TIMESTAMP DEFAULT NOW()); CREATE TABLE contacts(id INTEGER PRIMARY KEY,first_name TEXT,last_name TEXT,email TEXT,phone TEXT,mobile TEXT,title TEXT,department TEXT,user_company TEXT,is_active BOOLEAN,created_at TIMESTAMP,updated_at TIMESTAMP); CREATE TABLE contact_companies(contact_id INTEGER,customer_id INTEGER); INSERT INTO contacts(id,first_name,last_name,phone,mobile,is_active) VALUES(1,'Test','Kontakt','+45 12345678','87654321',TRUE),(2,'Inaktiv','Kontakt','12345678',NULL,FALSE); CREATE TABLE settings(key TEXT,value TEXT); CREATE TABLE tmodule_times (LIKE public.tmodule_times INCLUDING DEFAULTS); ALTER TABLE tmodule_times ALTER COLUMN id DROP DEFAULT; ALTER TABLE tmodule_times ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY; CREATE FUNCTION remote_test_update() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN NEW.updated_at=clock_timestamp();RETURN NEW;END$$; CREATE TRIGGER remote_test_update BEFORE UPDATE ON tmodule_times FOR EACH ROW EXECUTE FUNCTION remote_test_update(); INSERT INTO users(user_id,username,full_name) VALUES(1,'test1','Test One'),(2,'test2','Test Two'); INSERT INTO sag_sager(id,titel,status,ansvarlig_bruger_id) VALUES(11,'Ny testsag','Ny',1),(12,'Anden sag','I gang',1),(13,'Lukket sag','Lukket',1),(14,'Andens sag','Ny',2);''') database.execute_query(Path('migrations/1069_remote_control.sql').read_text()) yield permissions finally: pool.closeall() with root.cursor() as cur: cur.execute('DROP SCHEMA ' + schema + ' CASCADE') root.close() @pytest.fixture(params=["http", "https"]) def web(db, request): app = FastAPI(); app.include_router(router); app.include_router(views) async def hub_user(request: Request): uid = request.cookies.get('access_token') if uid not in {'hub1', 'hub2'}: raise HTTPException(401, 'Hub login required') uid = int(uid[-1]) return {'id': uid, 'username': 'test' + str(uid), 'full_name': 'Test', 'permissions': db[uid]} app.dependency_overrides[get_current_user] = hub_user @app.get('/ordinary') async def ordinary(user=__import__('fastapi').Depends(get_current_user)): return {'id': user['id']} client = TestClient(app, base_url=f'{request.param}://hub.test') client.cookies.set('access_token', 'hub1') csrf = client.get('/api/v1/remote/bootstrap').json()['csrf'] headers = {'Origin': str(client.base_url).rstrip('/'), 'X-Remote-CSRF': csrf} return client, headers def connect(web): client, headers = web assert client.put('/api/v1/remote/admin/pin', json={'pin': '1234'}, headers=headers).status_code == 200 remote = client.post('/api/v1/remote/admin/remotes', json={'name': 'Test remote'}, headers=headers).json() response = client.post('/api/v1/remote/connect', json={'code': remote['code'], 'pin': '1234'}, headers=headers) assert response.status_code == 200, response.text assert ('Secure' in response.headers['set-cookie']) == (client.base_url.scheme == 'https') assert 'HttpOnly' in response.headers['set-cookie'] return remote def post_command(web, action, **kwargs): client, headers = web snapshot = client.get('/api/v1/remote/state').json() payload = {'request_id': str(uuid4()), 'version': snapshot['version'], 'timer_version': snapshot['timer_version'], 'action': action, **kwargs} return client.post('/api/v1/remote/command', json=payload, headers=headers), payload def test_connection_isolation_pin_rotation_expiry(web, db): client, headers = web; remote = connect(web) assert client.get('/api/v1/remote/state').status_code == 200 # A remote cookie alone grants no full Hub or management access. client.cookies.delete('access_token') assert client.get('/ordinary').status_code == 401 assert client.get('/api/v1/remote/admin').status_code == 401 client.cookies.set('access_token', 'hub2') assert client.post(f"/api/v1/remote/admin/remotes/{remote['id']}/rotate", json={}, headers=headers).status_code == 404 assert client.get('/api/v1/remote/admin').json()['remotes'] == [] client.cookies.set('access_token', 'hub1') assert client.put('/api/v1/remote/admin/pin', json={'pin': '5678'}, headers=headers).status_code == 200 assert client.get('/api/v1/remote/state').status_code == 423 database.execute_query("UPDATE remote_attempts SET next_allowed_at='epoch'") assert client.post('/api/v1/remote/unlock', json={'pin': '5678'}, headers=headers).status_code == 200 database.execute_query("UPDATE remote_devices SET unlocked_date=unlocked_date-1") assert client.get('/api/v1/remote/state').status_code == 423 database.execute_query("UPDATE remote_devices SET expires_at=NOW()-INTERVAL '1 second'") assert client.get('/api/v1/remote/state').status_code == 401 def test_failed_attempts_are_committed_and_shared_between_connections(web): client, headers = web assert client.post('/api/v1/remote/connect', json={'code': '000000000042', 'pin': '0000'}, headers=headers).status_code == 401 second = TestClient(client.app, base_url=str(client.base_url)); second.cookies.update(client.cookies) result = second.post('/api/v1/remote/connect', json={'code': '000000000042', 'pin': '0000'}, headers=headers) assert result.status_code == 429 and 1 <= int(result.headers['Retry-After']) <= 30 assert database.execute_query_single('SELECT COUNT(*) AS count FROM remote_attempts')['count'] == 1 def test_rate_limit_is_atomic_across_concurrent_db_connections(db): def attempt(): try: security.reserve_attempt('isolated-ip', 1, uuid4()) return 200 except HTTPException as exc: return exc.status_code with ThreadPoolExecutor(max_workers=4) as executor: outcomes = list(executor.map(lambda _: attempt(), range(4))) assert sorted(outcomes) == [200, 429, 429, 429] def process_attempt(options): """Separate spawned worker: no shared Python lock, pool, or rate-limit memory.""" settings.REMOTE_LOOKUP_KEY = 'isolated-test-key-only-' * 3 database.connection_pool = ThreadedConnectionPool(1, 2, dsn=settings.DATABASE_URL, options=options) try: try: security.reserve_attempt('cross-process-ip', 1) return 200 except HTTPException as exc: return exc.status_code finally: database.connection_pool.closeall() def test_rate_limit_is_shared_between_processes(db): import multiprocessing conn = database.get_db_connection() options = conn.get_dsn_parameters()['options'] database.release_db_connection(conn) with ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as executor: assert sorted(executor.map(process_attempt, [options, options])) == [200, 429] def test_timer_actions_manual_time_idempotency_and_conflict(web): client, headers = web; connect(web) result, payload = post_command(web, 'start', case_id=11) assert result.status_code == 200, result.text assert client.post('/api/v1/remote/command', json=payload, headers=headers).json() == result.json() assert client.get('/api/v1/remote/command-result/' + payload['request_id']).json()['completed'] is True assert database.execute_query_single('SELECT COUNT(*) AS count FROM tmodule_times')['count'] == 1 payload['case_id'] = 12 assert client.post('/api/v1/remote/command', json=payload, headers=headers).status_code == 409 assert post_command(web, 'select', case_id=12)[0].status_code == 409 assert post_command(web, 'select', case_id=12, switch_timer=False)[0].status_code == 200 assert client.get('/api/v1/remote/state').json()['timer']['active']['sag_id'] == 11 assert post_command(web, 'select', case_id=12, switch_timer=True)[0].status_code == 200 snapshot = client.get('/api/v1/remote/state').json() assert snapshot['timer']['active']['sag_id'] == 12 assert post_command(web, 'pause')[0].status_code == 200 tid = snapshot['timer']['active']['time_entry_id'] assert post_command(web, 'resume', time_id=tid)[0].status_code == 200 assert post_command(web, 'stop', time_id=tid, description='Test arbejde')[0].status_code == 200 assert post_command(web, 'manual', case_id=12, description='Test manuel tid', minutes=17)[0].status_code == 200 assert client.get('/api/v1/remote/state').json()['today_minutes'] >= 17 # Desktop modifications invalidate an already prepared remote command. snapshot = client.get('/api/v1/remote/state').json() payload = dict(request_id=str(uuid4()), version=snapshot['version'], timer_version=snapshot['timer_version'], action='start', case_id=11) import importlib timers = importlib.import_module('app.timetracking.backend.router') asyncio.run(timers.start_live_timer_v1({'sag_id': 12, 'billing_mode': 'automatic'}, {'id': 1, 'username': 'test1'})) assert client.post('/api/v1/remote/command', json=payload, headers=headers).status_code == 409 def test_concurrent_commands_cannot_create_duplicate_timers(web): client, headers = web; connect(web) snapshot = client.get('/api/v1/remote/state').json() def start(_): payload = Command(request_id=uuid4(), version=snapshot['version'], timer_version=snapshot['timer_version'], action='start', case_id=11) try: asyncio.run(service.command(payload, {'id': 1, 'username': 'test1', 'permissions': ['cases.view']})) return 200 except HTTPException as exc: return exc.status_code with ThreadPoolExecutor(max_workers=2) as executor: assert sorted(executor.map(start, range(2))) == [200, 409] assert database.execute_query_single('SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer')['count'] == 1 def test_only_selected_follower_and_no_stale_selection(web): client, headers = web; connect(web) first, second = str(uuid4()), str(uuid4()) client.post('/api/v1/remote/follow', json={'tab_id': first, 'enable': True}, headers=headers) assert post_command(web, 'select', case_id=11)[0].status_code == 200 event = client.post('/api/v1/remote/follow', json={'tab_id': first}, headers=headers).json() assert event['selection'] == {'case_id': 11} client.post('/api/v1/remote/follow', json={'tab_id': second, 'enable': True}, headers=headers) assert client.post('/api/v1/remote/follow', json={'tab_id': first}, headers=headers).json()['following'] is False assert post_command(web, 'select', case_id=12)[0].status_code == 200 database.execute_query("UPDATE remote_user_state SET follower_seen_at=NOW()-INTERVAL '15 seconds'") event = client.post('/api/v1/remote/follow', json={'tab_id': second}, headers=headers).json() assert event['selection'] is None def test_case_filters_permissions_and_revocation(web, db): client, headers = web; remote = connect(web) assert {c['id'] for c in client.get('/api/v1/remote/cases?kind=new').json()['items']} == {11,14} assert {c['id'] for c in client.get('/api/v1/remote/cases?kind=own').json()['items']} == {11,12} assert client.get('/api/v1/remote/cases?q=11').json()['items'][0]['id'] == 11 db[1] = [] assert client.get('/api/v1/remote/cases').status_code == 403 assert post_command(web, 'start', case_id=11)[0].status_code == 403 db[1] = ['cases.view'] assert client.request('DELETE', f"/api/v1/remote/admin/remotes/{remote['id']}", json={}, headers=headers).status_code == 200 assert client.get('/api/v1/remote/state').status_code == 401 def test_leading_zero_code_and_no_hashes_in_administration(web, monkeypatch): client, headers = web monkeypatch.setattr('app.modules.remote.router.new_code', lambda: '000000000042') remote = connect(web) assert remote['code'] == '000000000042' admin = client.get('/api/v1/remote/admin').json() assert not {'code', 'code_hash', 'code_lookup', 'pin_hash', 'token_hash'}.intersection(admin['remotes'][0]) assert not {'token_hash', 'pin_hash'}.intersection(admin['devices'][0]) device_id = admin['devices'][0]['id'] assert client.request('DELETE', '/api/v1/remote/admin/devices/' + device_id, json={}, headers=headers).status_code == 200 assert client.get('/api/v1/remote/state').status_code == 401 def test_rotate_code_revokes_devices_and_inactive_user_is_denied(web): client, headers = web; remote = connect(web) result = client.post(f"/api/v1/remote/admin/remotes/{remote['id']}/rotate", json={}, headers=headers) assert result.status_code == 200 and result.json()['code'] != remote['code'] assert client.get('/api/v1/remote/state').status_code == 401 database.execute_query("UPDATE remote_attempts SET next_allowed_at='epoch'") assert client.post('/api/v1/remote/connect', json={'code': result.json()['code'], 'pin': '1234'}, headers=headers).status_code == 200 database.execute_query('UPDATE users SET is_active=FALSE WHERE user_id=1') assert client.get('/api/v1/remote/state').status_code == 401 def test_wrong_pin_and_reconnect_do_not_bypass_cooldown(web): client, headers = web; connect(web) database.execute_query("UPDATE remote_attempts SET next_allowed_at='epoch'") database.execute_query('UPDATE remote_devices SET unlocked_date=unlocked_date-1') assert client.post('/api/v1/remote/unlock', json={'pin': '9999'}, headers=headers).status_code == 401 assert client.post('/api/v1/remote/unlock', json={'pin': '1234'}, headers=headers).status_code == 429 assert client.get('/api/v1/remote/state').status_code == 423 def test_message_read_is_explicit_and_uses_current_user(web, monkeypatch): client, _ = web; connect(web) calls = [] monkeypatch.setattr(service, 'mark_user_messages_read', lambda uid, partner_user_id=None: calls.append((uid,partner_user_id)) or 1) assert client.get('/api/v1/remote/state').status_code == 200 assert calls == [] assert post_command(web, 'read', partner_user_id=2)[0].status_code == 200 assert calls == [(1,2)] def test_desktop_start_and_remote_start_share_user_lock(web): client, _ = web; connect(web) snapshot = client.get('/api/v1/remote/state').json() user = {'id': 1, 'username': 'test1', 'permissions': ['cases.view']} def start_remote(): payload = Command(request_id=uuid4(), version=snapshot['version'], timer_version=snapshot['timer_version'], action='start', case_id=11) try: asyncio.run(service.command(payload,user)) return 200 except HTTPException as exc: return exc.status_code def start_desktop(): import importlib timers = importlib.import_module('app.timetracking.backend.router') asyncio.run(timers.start_live_timer_v1({'sag_id':12,'billing_mode':'automatic'},user)) return 200 with ThreadPoolExecutor(max_workers=2) as executor: outcomes = [executor.submit(start_remote),executor.submit(start_desktop)] assert all(f.result() in {200,409} for f in outcomes) assert database.execute_query_single('SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer')['count'] == 1 def test_real_hub_jwt_remains_separate_and_revocable(web): client, _ = web; connect(web) client.app.dependency_overrides.clear() token = AuthService.create_access_token(1, 'test1') client.cookies.set('access_token', token) assert client.get('/ordinary').json() == {'id': 1} assert client.get('/api/v1/remote/admin').status_code == 200 client.cookies.set('access_token', client.cookies.get(security.COOKIE)) assert client.get('/ordinary').status_code == 401 client.cookies.set('access_token', token) database.execute_query('UPDATE sessions SET revoked=TRUE') assert client.get('/ordinary').status_code == 401 assert client.get('/api/v1/remote/admin').status_code == 401 assert client.get('/api/v1/remote/state').status_code == 200 def test_remote_contacts_and_owner_only_call(web, db, monkeypatch): from app.modules.telefoni.backend import router as phone_router client, headers = web; connect(web) assert client.get('/api/v1/remote/contacts').status_code == 403 db[1].append('customers.view') result = client.get('/api/v1/remote/contacts?q=Kontakt').json() assert len(result['items']) == 1 and result['items'][0]['id'] == 1 calls = [] async def fake_call(payload, request): calls.append((payload.model_dump(), request.state.user_id)) return {'action_url': 'secret-must-not-be-returned'} monkeypatch.setattr(phone_router, 'click_to_call', fake_call) payload = {'request_id': str(uuid4()), 'contact_id': 1, 'field': 'mobile'} first = client.post('/api/v1/remote/call', json=payload, headers=headers) assert first.status_code == 200 and first.json()['status'] == 'ok' assert 'secret' not in first.text assert client.post('/api/v1/remote/call', json=payload, headers=headers).json() == first.json() assert len(calls) == 1 and calls[0][0]['user_id'] == 1 and calls[0][1] == 1 assert calls[0][0]['number'] == '87654321' and calls[0][0]['extension'] is None assert client.get('/api/v1/remote/call/' + payload['request_id']).json() == first.json() assert client.post('/api/v1/remote/call', json={**payload, 'user_id': 2}, headers=headers).status_code == 422 assert client.post('/api/v1/remote/call', json={**payload, 'field': 'phone'}, headers=headers).status_code == 409 assert client.post('/api/v1/remote/call', json={**payload, 'request_id': str(uuid4()), 'contact_id': 2}, headers=headers).status_code == 404 def test_uncertain_call_is_not_repeated(web, db, monkeypatch): from app.modules.telefoni.backend import router as phone_router client, headers = web; connect(web); db[1].append('customers.view') calls = [] async def failed_call(payload, request): calls.append(payload) raise HTTPException(502, 'timeout') monkeypatch.setattr(phone_router, 'click_to_call', failed_call) payload = {'request_id': str(uuid4()), 'contact_id': 1, 'field': 'phone'} assert client.post('/api/v1/remote/call', json=payload, headers=headers).json()['status'] == 'uncertain' assert client.post('/api/v1/remote/call', json=payload, headers=headers).json()['status'] == 'uncertain' assert len(calls) == 1 assert client.get('/api/v1/remote/call/' + str(uuid4())).json()['status'] == 'unknown' assert len(calls) == 1 def test_incoming_calls_use_shared_state_and_ignore_stale(monkeypatch): from app.modules.remote import telephony now = datetime.now(timezone.utc) monkeypatch.setattr(telephony.MissionService, 'get_active_calls', lambda: [ {'call_id':'fresh','updated_at':now,'caller_number':'12345678','ringing_phones':['Support']}, {'call_id':'stale','updated_at':now-timedelta(minutes=10)}]) user = {'id': 1, 'permissions': ['customers.view']} assert [c['call_id'] for c in telephony.status(user)['incoming']] == ['fresh'] assert telephony.status({'id':2,'permissions':[]}) == {'allowed':False,'incoming':[]} def test_concurrent_same_call_reserves_before_external_action(web, db, monkeypatch): from app.modules.telefoni.backend import router as phone_router client, headers = web; connect(web); db[1].append('customers.view') calls = [] async def slow_call(payload, request): calls.append(payload.user_id) await asyncio.sleep(0.1) return {'status': 'ok'} monkeypatch.setattr(phone_router, 'click_to_call', slow_call) payload = {'request_id': str(uuid4()), 'contact_id': 1, 'field': 'phone'} def send(_): with TestClient(client.app, base_url=str(client.base_url)) as other: other.cookies.update(client.cookies) return other.post('/api/v1/remote/call', json=payload, headers=headers) with ThreadPoolExecutor(max_workers=2) as workers: responses = list(workers.map(send, range(2))) assert all(response.status_code == 200 for response in responses) assert calls == [1] assert client.get('/api/v1/remote/call/' + payload['request_id']).json()['status'] == 'ok' def test_remote_planned_day_is_scoped_to_user_and_danish_day(monkeypatch): from datetime import date captured = [] monkeypatch.setattr(service, 'case_permission', lambda user: None) monkeypatch.setattr(service, 'execute_query', lambda sql, params: captured.append((sql, params)) or []) result = service.planned_day({'id': 7}, date(2026, 10, 14)) assert result == {'date': '2026-10-14', 'items': []} sql, params = captured[0] assert params[0] == params[3] == 7 assert params[1].hour == params[2].hour == 0 assert params[1].day == 15 and params[2].day == 14 assert params[2].utcoffset() == timedelta(hours=2) assert "q.state='planned'" in sql and 'a.deleted_at IS NULL' in sql def test_remote_planned_day_uses_dst_boundaries(monkeypatch): from datetime import date calls = [] monkeypatch.setattr(service, 'case_permission', lambda user: None) monkeypatch.setattr(service, 'execute_query', lambda sql, params: calls.append(params) or []) service.planned_day({'id': 7}, date(2026, 10, 25)) end, start = calls[0][1:3] assert (end.astimezone(timezone.utc) - start.astimezone(timezone.utc)) == timedelta(hours=25)