"""调度器安全相关测试:SSRF 防护 + 单实例防护 + 统一线程池。""" from modules.job import scheduler as sch def test_build_full_url(): assert sch.build_full_url('/api/x') == f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/x" assert sch.build_full_url('http://a.com/x') == 'http://a.com/x' assert sch.build_full_url('ftp://a.com/x') == 'ftp://a.com/x' def test_validate_http_url_rejects_internal_ips(): for url in ( 'http://127.0.0.1:6379/', # 回环 'http://10.0.0.1/x', # 私有A类 'http://169.254.169.254/latest', # 云元数据 'http://192.168.1.1/x', # 私有C类 'http://172.16.5.5/x', # 私有B类 ): ok, reason = sch._validate_http_url(url) assert ok is False, url assert '禁止' in reason or '内网' in reason def test_validate_http_url_rejects_non_http_scheme(): for url in ('ftp://a.com/x', 'file:///etc/passwd', 'javascript:alert(1)'): ok, reason = sch._validate_http_url(url) assert ok is False, url assert '协议' in reason def test_validate_http_url_accepts_public_ip(): # 公网 IP 字面量,无需真实 DNS ok, reason = sch._validate_http_url('http://93.184.216.34/') assert ok is True, reason def test_execute_by_url_blocks_internal_url(monkeypatch): sent = [] monkeypatch.setattr( sch, '_send_sync_request', lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True}) result = sch._execute_by_url(1, 'http://127.0.0.1:6379', 1) assert result['success'] is False assert '禁止' in result['error'] or '内网' in result['error'] assert sent == [] # 未实际发起请求 def test_execute_by_url_relative_self_call_allowed(monkeypatch): sent = [] monkeypatch.setattr( sch, '_send_sync_request', lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True}) result = sch._execute_by_url(1, '/api/ping', 1) assert result['success'] is True assert sent == [f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/ping"] # ============================================================ # SSRF DNS 重绑定防护:预解析后固定 IP 直连,杜绝二次解析域名 # ============================================================ def test_resolve_and_pin_returns_pinned_ip(): # 公网 IP 字面量,无需真实 DNS ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34/x') assert ok is True, reason assert ip == '93.184.216.34' def test_resolve_and_pin_rejects_custom_port(): ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34:8080/x') assert ok is False assert '端口' in reason assert ip is None def test_resolve_and_pin_allows_80_443(): for url in ('http://93.184.216.34:80/x', 'https://93.184.216.34:443/x'): ok, reason, ip = sch._resolve_and_pin(url) assert ok is True, reason assert ip == '93.184.216.34' def test_pinned_connection_pins_dns_host_keeps_real_host(): # HTTP 与 HTTPS 连接:_dns_host 固定为预校验 IP(实际建连目标), # host 保持真实主机名(Host 头 / TLS SNI)——同域名只解析一次,杜绝重绑定。 for cls, port in ((sch._PinnedHTTPConnection, 80), (sch._PinnedHTTPSConnection, 443)): conn = cls(host='example.com', port=port, pinned_ip='93.184.216.34') assert conn._dns_host == '93.184.216.34' assert conn.host == 'example.com' def test_send_sync_request_uses_pinned_ip_when_given(monkeypatch): calls = [] class _FakeResp: status_code = 200 def raise_for_status(self): pass def json(self): return {'ok': 1} monkeypatch.setattr( sch, '_post_pinned', lambda url, data, headers, timeout, pinned_ip: calls.append((url, pinned_ip)) or _FakeResp()) result = sch._send_sync_request('http://example.com/x', {'a': 1}, 1, '93.184.216.34') assert result['success'] is True assert calls == [('http://example.com/x', '93.184.216.34')] def test_send_sync_request_without_pinned_ip_uses_plain_post(monkeypatch): calls = [] class _FakeResp: status_code = 200 def raise_for_status(self): pass def json(self): return {'ok': 1} monkeypatch.setattr( sch, '_post_pinned', lambda *a, **k: calls.append('pinned') or _FakeResp()) monkeypatch.setattr( sch.requests, 'post', lambda url, **kw: calls.append('plain') or _FakeResp()) # 相对路径自调用等可信场景无固定 IP,应走常规 requests.post 而非 pinned 路径 result = sch._send_sync_request('http://127.0.0.1:8031/api/ping', {'a': 1}, 1) assert result['success'] is True assert calls == ['plain'] def test_job_single_instance_claim(monkeypatch): # 禁用 Redis 锁(退化进程内互斥),使进程内 set 行为可独立验证 monkeypatch.setattr(sch, '_get_lock_redis', lambda: None) assert sch._try_claim_job(42) is True assert sch._try_claim_job(42) is False # 已占用,拒绝重入 sch._release_job(42) assert sch._try_claim_job(42) is True sch._release_job(42) # ============================================================ # Redis 分布式锁:跨 worker 进程单实例互斥 # ============================================================ def test_try_claim_job_respects_cross_process_redis_lock(monkeypatch): import fakeredis fake = fakeredis.FakeRedis() monkeypatch.setattr(sch, '_get_lock_redis', lambda: fake) # 进程内重复触发仍被本地 set 拦截 assert sch._try_claim_job(7) is True assert sch._try_claim_job(7) is False sch._release_job(7) assert sch._try_claim_job(7) is True sch._release_job(7) # 模拟他进程已持锁:Redis 锁被占用 → 本进程拒绝,且本地占用已回滚 fake.set(f'{sch._JOB_LOCK_PREFIX}9', '1', nx=True, ex=300) assert sch._try_claim_job(9) is False assert 9 not in sch._running_jobs # 释放后(他进程删除锁)可再次占用 fake.delete(f'{sch._JOB_LOCK_PREFIX}9') assert sch._try_claim_job(9) is True sch._release_job(9) def test_try_claim_job_degrades_when_redis_unavailable(monkeypatch): # Redis 不可用 → 退化进程内互斥,不中断任务执行 monkeypatch.setattr(sch, '_get_lock_redis', lambda: None) assert sch._try_claim_job(10) is True assert sch._try_claim_job(10) is False # 本地 set 仍生效 sch._release_job(10) # ============================================================ # _handle_scheduler_update:运行中任务编辑后必须重加调度器刷新执行参数 # ============================================================ def _patch_scheduler_calls(monkeypatch): """打桩 job.service 中的调度器函数,记录 add/remove 调用。""" from modules.job import service as job_svc calls = {'add': [], 'remove': []} monkeypatch.setattr( job_svc, 'add_job_to_scheduler', lambda data: calls['add'].append(data) or True) monkeypatch.setattr( job_svc, 'remove_job_from_scheduler', lambda jid: calls['remove'].append(jid) or True) return job_svc, calls def test_handle_scheduler_update_running_edit_readds(monkeypatch): """运行中任务编辑 url/params:cron 未变也需重加,否则调度器仍按旧参数执行。""" job_svc, calls = _patch_scheduler_calls(monkeypatch) ok = job_svc._handle_scheduler_update( {'id': 5, 'status': 1, 'url': '/api/new'}, old_status=1, old_cron='0 * * * *') assert ok is True assert calls['remove'] == [] assert len(calls['add']) == 1 # 重加时携带完整任务状态与有效 cron(部分更新缺 cron 时沿用旧值) added = calls['add'][0] assert added['id'] == 5 assert added['url'] == '/api/new' assert added['cron_expression'] == '0 * * * *' def test_handle_scheduler_update_running_explicit_new_cron(monkeypatch): """运行中任务显式改 cron:以新 cron 重加。""" job_svc, calls = _patch_scheduler_calls(monkeypatch) ok = job_svc._handle_scheduler_update( {'id': 5, 'status': 1, 'cron_expression': '*/5 * * * *'}, old_status=1, old_cron='0 * * * *') assert ok is True assert calls['add'][0]['cron_expression'] == '*/5 * * * *' def test_handle_scheduler_update_running_to_disabled_removes(monkeypatch): """运行中 → 禁用:从调度器移除。""" job_svc, calls = _patch_scheduler_calls(monkeypatch) ok = job_svc._handle_scheduler_update( {'id': 5, 'status': 2}, old_status=1, old_cron='0 * * * *') assert ok is True assert calls['add'] == [] assert calls['remove'] == [5] def test_handle_scheduler_update_disabled_to_running_readds(monkeypatch): """暂停 → 启用且带 cron:重加。""" job_svc, calls = _patch_scheduler_calls(monkeypatch) ok = job_svc._handle_scheduler_update( {'id': 5, 'status': 1, 'cron_expression': '0 * * * *'}, old_status=2, old_cron='0 * * * *') assert ok is True assert len(calls['add']) == 1 assert calls['remove'] == [] def test_handle_scheduler_update_disabled_to_running_without_cron(monkeypatch): """暂停 → 启用但无 cron:无法调度,移除(保持调度器一致性)。""" job_svc, calls = _patch_scheduler_calls(monkeypatch) ok = job_svc._handle_scheduler_update( {'id': 5, 'status': 1}, old_status=2, old_cron=None) assert ok is True assert calls['add'] == [] assert calls['remove'] == [5] def test_handle_scheduler_update_no_status_change_noop(monkeypatch): """保持暂停:不触发任何调度器操作。""" job_svc, calls = _patch_scheduler_calls(monkeypatch) ok = job_svc._handle_scheduler_update( {'id': 5, 'status': 2}, old_status=2, old_cron=None) assert ok is True assert calls['add'] == [] assert calls['remove'] == [] # ============================================================ # _build_cron_trigger:5/6 字段 cron,6 字段首字段为秒(秒级触发) # ============================================================ def _fire_seconds_away(cron_expr): """返回 (next_fire, 距 now 的秒数)。now 取本地 aware 时间,与触发器默认时区对齐。""" from datetime import datetime now = datetime.now().astimezone() nf = sch._build_cron_trigger(cron_expr).get_next_fire_time(None, now) return nf, (nf - now).total_seconds() if nf else None def test_build_cron_trigger_5_fields_minute_granularity(): nf, delta = _fire_seconds_away('0 * * * *') # 每小时整点 assert nf is not None assert 0 < delta <= 3600 def test_build_cron_trigger_6_fields_second_granularity(): nf, delta = _fire_seconds_away('*/15 * * * * *') # 每 15 秒 assert nf is not None assert 0 < delta <= 15, f"秒级 cron 应 15s 内触发,实际 {delta}s" def test_build_cron_trigger_5_field_matches_from_crontab(): """5 字段行为与 CronTrigger.from_crontab 完全一致(向后兼容)。""" from apscheduler.triggers.cron import CronTrigger from datetime import datetime now = datetime.now().astimezone() t1 = sch._build_cron_trigger('0 * * * *').get_next_fire_time(None, now) t2 = CronTrigger.from_crontab('0 * * * *').get_next_fire_time(None, now) assert t1 == t2 def test_build_cron_trigger_invalid_field_count_raises(): import pytest for bad in ('', '* * * *', '* * * * * * *'): # 0/4/7 字段均非法 with pytest.raises(ValueError): sch._build_cron_trigger(bad)