Files
theHarvester/tests/lib/test_source_runner.py
T

836 lines
32 KiB
Python

from __future__ import annotations
import asyncio
import logging
from dataclasses import FrozenInstanceError
from datetime import UTC, datetime
from typing import Any
import pytest
from theHarvester.discovery.constants import MissingKeyError
from theHarvester.lib.asn_attribution import AsnAttributionObservation
from theHarvester.lib.completed_result import ResultObservation, SourceExecution
from theHarvester.lib.source_catalog import SOURCE_SPECS
from theHarvester.lib.source_runner import (
SOURCE_FACTORIES,
SourceJob,
SourceOutcome,
SourceRequest,
create_source,
run_source,
run_source_jobs,
)
@pytest.mark.parametrize('workers', [0, -1, True, 1.5])
@pytest.mark.asyncio
async def test_source_jobs_require_a_positive_worker_count(workers: object) -> None:
with pytest.raises(ValueError, match='source workers must be a positive integer'):
await run_source_jobs((), workers=workers) # type: ignore[arg-type]
def test_source_contracts_are_immutable() -> None:
request = SourceRequest('APIS-GURU', 'example.test', 25, 5, True, True)
job = SourceJob(request)
outcome = SourceOutcome(SourceExecution('apis-guru', 'completed', 0, 0))
with pytest.raises(FrozenInstanceError):
request.target = 'changed.test' # type: ignore[misc]
with pytest.raises(FrozenInstanceError):
job.request = request # type: ignore[misc]
with pytest.raises(FrozenInstanceError):
outcome.observations = () # type: ignore[misc]
assert request.source == 'apis-guru'
def test_source_factories_match_the_catalog() -> None:
assert set(SOURCE_FACTORIES) == set(SOURCE_SPECS)
@pytest.mark.parametrize(
('source', 'patch_target', 'expected_args', 'expected_kwargs'),
[
('apis-guru', 'theHarvester.lib.source_runner.apisguru.SearchApisGuru', ('example.test', 25), {}),
('arquivo', 'theHarvester.lib.source_runner.arquivo.SearchArquivo', ('example.test', 25), {}),
('baidu', 'theHarvester.lib.source_runner.baidusearch.SearchBaidu', ('example.test', 25), {}),
('bevigil', 'theHarvester.lib.source_runner.bevigil.SearchBeVigil', ('example.test',), {}),
('brave', 'theHarvester.lib.source_runner.bravesearch.SearchBrave', ('example.test', 25), {}),
('bufferoverun', 'theHarvester.lib.source_runner.bufferoverun.SearchBufferover', ('example.test',), {}),
('builtwith', 'theHarvester.lib.source_runner.builtwith.SearchBuiltWith', ('example.test',), {}),
('censys', 'theHarvester.lib.source_runner.censysearch.SearchCensys', ('example.test', 25), {}),
('certspotter', 'theHarvester.lib.source_runner.certspottersearch.SearchCertspoter', ('example.test',), {}),
('commoncrawl', 'theHarvester.lib.source_runner.commoncrawl.SearchCommoncrawl', ('example.test', 25), {}),
('criminalip', 'theHarvester.lib.source_runner.criminalip.SearchCriminalIP', ('example.test',), {}),
('crt-name', 'theHarvester.lib.source_runner.crtname.SearchCrtName', ('example.test',), {}),
('crtsh', 'theHarvester.lib.source_runner.crtsh.SearchCrtsh', ('example.test',), {}),
('dehashed', 'theHarvester.lib.source_runner.search_dehashed.SearchDehashed', ('example.test',), {'limit': 25}),
('dnsdb', 'theHarvester.lib.source_runner.dnsdb.SearchDNSDB', ('example.test',), {}),
(
'dnsdumpster',
'theHarvester.lib.source_runner.search_dnsdumpster.SearchDNSDumpster',
('example.test',),
{},
),
('duckduckgo', 'theHarvester.lib.source_runner.duckduckgosearch.SearchDuckDuckGo', ('example.test', 25), {}),
('dymo', 'theHarvester.lib.source_runner.dymosearch.SearchDymo', ('example.test',), {}),
('fofa', 'theHarvester.lib.source_runner.fofa.SearchFofa', ('example.test', 25), {}),
('fullhunt', 'theHarvester.lib.source_runner.fullhuntsearch.SearchFullHunt', ('example.test',), {}),
('github-code', 'theHarvester.lib.source_runner.githubcode.SearchGithubCode', ('example.test', 25), {}),
('gitlab', 'theHarvester.lib.source_runner.gitlabsearch.SearchGitlab', ('example.test',), {}),
(
'hackertarget',
'theHarvester.lib.source_runner.hackertarget.SearchHackerTarget',
('example.test',),
{},
),
(
'haveibeenpwned',
'theHarvester.lib.source_runner.haveibeenpwned.SearchHaveIBeenPwned',
('example.test',),
{},
),
(
'hibpverified',
'theHarvester.lib.source_runner.hibpverified.SearchHibpVerified',
('example.test',),
{},
),
('hudsonrock', 'theHarvester.lib.source_runner.hudsonrocksearch.SearchHudsonRock', ('example.test',), {}),
('hunter', 'theHarvester.lib.source_runner.huntersearch.SearchHunter', ('example.test', 25, 5), {}),
('hunterhow', 'theHarvester.lib.source_runner.searchhunterhow.SearchHunterHow', ('example.test', 25), {}),
('intelx', 'theHarvester.lib.source_runner.intelxsearch.SearchIntelx', ('example.test',), {}),
('leakix', 'theHarvester.lib.source_runner.leakix.SearchLeakix', ('example.test',), {}),
('leaklookup', 'theHarvester.lib.source_runner.leaklookup.SearchLeakLookup', ('example.test',), {}),
('mojeek', 'theHarvester.lib.source_runner.mojeek.SearchMojeek', ('example.test', 25), {}),
('netlas', 'theHarvester.lib.source_runner.netlas.SearchNetlas', ('example.test', 25), {}),
('onyphe', 'theHarvester.lib.source_runner.onyphe.SearchOnyphe', ('example.test', 25), {}),
('otx', 'theHarvester.lib.source_runner.otxsearch.SearchOtx', ('example.test',), {}),
(
'pentesttools',
'theHarvester.lib.source_runner.pentesttools.SearchPentestTools',
('example.test',),
{},
),
(
'projectdiscovery',
'theHarvester.lib.source_runner.projectdiscovery.SearchDiscovery',
('example.test',),
{},
),
('rapiddns', 'theHarvester.lib.source_runner.rapiddns.SearchRapidDns', ('example.test',), {}),
('robtex', 'theHarvester.lib.source_runner.robtex.SearchRobtex', ('example.test',), {}),
('rocketreach', 'theHarvester.lib.source_runner.rocketreach.SearchRocketReach', ('example.test', 25), {}),
(
'securityTrails',
'theHarvester.lib.source_runner.securitytrailssearch.SearchSecuritytrail',
('example.test',),
{},
),
(
'securityscorecard',
'theHarvester.lib.source_runner.securityscorecard.SearchSecurityScorecard',
('example.test', 25),
{},
),
(
'sherlockeye',
'theHarvester.lib.source_runner.sherlockeye.SearchSherlockeye',
('example.test',),
{},
),
('shodan', 'theHarvester.lib.source_runner.shodansearch.SearchShodan', ('example.test',), {}),
(
'shodanInternetDB',
'theHarvester.lib.source_runner.shodan_internetdb.SearchShodanInternetDB',
('example.test',),
{},
),
('shodanct', 'theHarvester.lib.source_runner.shodanct.SearchShodanCt', ('example.test',), {}),
('sourcegraph', 'theHarvester.lib.source_runner.sourcegraph.SearchSourcegraph', ('example.test', 25), {}),
(
'subdomaincenter',
'theHarvester.lib.source_runner.subdomaincenter.SubdomainCenter',
('example.test',),
{},
),
(
'subdomainfinderc99',
'theHarvester.lib.source_runner.subdomainfinderc99.SearchSubdomainfinderc99',
('example.test',),
{},
),
('thc', 'theHarvester.lib.source_runner.thc.SearchThc', ('example.test',), {}),
('tomba', 'theHarvester.lib.source_runner.tombasearch.SearchTomba', ('example.test', 25, 5), {}),
('urlscan', 'theHarvester.lib.source_runner.urlscan.SearchUrlscan', ('example.test', 25), {}),
('virustotal', 'theHarvester.lib.source_runner.virustotal.SearchVirustotal', ('example.test', 25), {}),
(
'waybackarchive',
'theHarvester.lib.source_runner.waybackarchive.SearchWaybackarchive',
('example.test', 25),
{},
),
('whoisxml', 'theHarvester.lib.source_runner.whoisxml.SearchWhoisXML', ('example.test', 25), {}),
('windvane', 'theHarvester.lib.source_runner.windvane.SearchWindvane', ('example.test',), {}),
('yahoo', 'theHarvester.lib.source_runner.yahoosearch.SearchYahoo', ('example.test', 25), {}),
('zoomeye', 'theHarvester.lib.source_runner.zoomeyesearch.SearchZoomEye', ('example.test', 25), {}),
],
)
def test_factory_constructor_shapes(
monkeypatch: pytest.MonkeyPatch,
source: str,
patch_target: str,
expected_args: tuple[object, ...],
expected_kwargs: dict[str, object],
) -> None:
calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
def constructor(*args: object, **kwargs: object) -> object:
calls.append((args, kwargs))
return object()
monkeypatch.setattr(patch_target, constructor)
create_source(SourceRequest(source, 'example.test', 25, 5, True, False))
assert calls == [(expected_args, expected_kwargs)]
@pytest.mark.asyncio
async def test_runner_normalizes_only_declared_apis_guru_routes(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeApisGuru:
execution_status = 'completed'
stop_reason = None
def __init__(self, target: str, limit: int) -> None:
assert (target, limit) == ('example.test', 25)
async def process(self, proxy: bool) -> None:
assert proxy is True
async def get_hostnames(self) -> list[str]:
return ['API.Example.TEST.', 'api.example.test', 'outside.test', 'example.test']
async def get_emails(self) -> list[str]:
return ['User@Example.TEST', 'user@example.test']
async def get_urls(self) -> list[str]:
return ['https://api.example.test/v1', 'https://api.example.test/v1']
async def get_ips(self) -> set[str]:
raise AssertionError('undeclared getter must not be read')
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda request: FakeApisGuru(request.target, request.limit))
outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 5, True, True))
assert outcome.execution.source == 'apis-guru'
assert outcome.execution.status == 'completed'
assert outcome.execution.result_count == 3
assert outcome.execution.stop_reason is None
assert outcome.observations == (
ResultObservation('apis-guru', 'email', 'user@example.test'),
ResultObservation('apis-guru', 'hostname', 'api.example.test'),
ResultObservation('apis-guru', 'url', 'https://api.example.test/v1'),
)
assert outcome.asn_attributions == ()
@pytest.mark.asyncio
async def test_runner_times_construction_and_records_missing_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
ticks = iter((10.0, 10.125))
events: list[str] = []
def clock() -> float:
events.append('clock')
return next(ticks)
def missing_factory(_request: SourceRequest) -> Any:
assert events == ['clock']
raise MissingKeyError('apis-guru')
monkeypatch.setattr('theHarvester.lib.source_runner.time.perf_counter', clock)
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', missing_factory)
outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 0, False, True))
assert outcome.execution == SourceExecution(
'apis-guru',
'skipped',
125,
0,
'MissingKeyError',
'missing-credentials',
)
@pytest.mark.asyncio
async def test_start_reporter_failure_is_sanitized_and_does_not_change_provider_outcome(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
process_called = False
class SuccessfulAdapter:
async def process(self, _proxy: bool) -> None:
nonlocal process_called
process_called = True
async def get_hostnames(self) -> set[str]:
return {'fresh.example.test'}
async def get_emails(self) -> set[str]:
return set()
async def get_ips(self) -> set[str]:
return set()
async def get_urls(self) -> set[str]:
return set()
def broken_reporter(_request: SourceRequest) -> None:
raise RuntimeError('sensitive callback payload')
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: SuccessfulAdapter())
caplog.set_level(logging.WARNING, logger='theHarvester.lib.source_runner')
outcome = await run_source(
SourceRequest('apis-guru', 'example.test', 25, 0, False, True),
on_started=broken_reporter,
)
assert process_called is True
assert outcome.execution.status == 'completed'
assert outcome.execution.error_type is None
assert outcome.observations == (ResultObservation('apis-guru', 'hostname', 'fresh.example.test'),)
assert 'Source start reporter failed for apis-guru: RuntimeError' in caplog.text
assert 'sensitive callback payload' not in caplog.text
@pytest.mark.asyncio
async def test_start_reporter_cancellation_propagates_without_collecting_pre_process_results(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cancellation = asyncio.CancelledError('reporter cancelled')
committed: list[SourceOutcome] = []
process_called = False
getter_called = False
class UnstartedAdapter:
async def process(self, _proxy: bool) -> None:
nonlocal process_called
process_called = True
async def get_hostnames(self) -> set[str]:
nonlocal getter_called
getter_called = True
return {'stale.example.test'}
def cancelled_reporter(_request: SourceRequest) -> None:
raise cancellation
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: UnstartedAdapter())
with pytest.raises(asyncio.CancelledError) as raised:
await run_source(
SourceRequest('apis-guru', 'example.test', 25, 0, False, True),
commit_cancelled=committed.append,
on_started=cancelled_reporter,
)
assert raised.value is cancellation
assert process_called is False
assert getter_called is False
assert committed[0].execution.status == 'failed'
assert committed[0].execution.stop_reason == 'cancelled'
assert committed[0].observations == ()
@pytest.mark.asyncio
async def test_runner_retains_earlier_observations_when_a_later_getter_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class PartiallyFailingApisGuru:
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'API.Example.TEST.'}
async def get_emails(self) -> set[str]:
raise RuntimeError('email projection failed')
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: PartiallyFailingApisGuru())
outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 0, False, True))
assert outcome.execution.status == 'partial'
assert outcome.execution.error_type == 'RuntimeError'
assert outcome.execution.result_count == 1
assert outcome.observations == (ResultObservation('apis-guru', 'hostname', 'api.example.test'),)
@pytest.mark.asyncio
async def test_runner_reads_retained_adapter_evidence_after_process_failure(monkeypatch: pytest.MonkeyPatch) -> None:
class PartiallyFailingSourcegraph:
async def process(self, _proxy: bool) -> None:
raise RuntimeError('stream failed')
async def get_hostnames(self) -> set[str]:
return {'partial.example.test'}
monkeypatch.setitem(SOURCE_FACTORIES, 'sourcegraph', lambda _request: PartiallyFailingSourcegraph())
outcome = await run_source(SourceRequest('sourcegraph', 'example.test', 25, 0, False, True))
assert outcome.execution.status == 'partial'
assert outcome.execution.error_type == 'RuntimeError'
assert outcome.observations == (ResultObservation('sourcegraph', 'hostname', 'partial.example.test'),)
@pytest.mark.asyncio
async def test_runner_reports_normal_zero_yield_as_completed_no_results(monkeypatch: pytest.MonkeyPatch) -> None:
class EmptySourcegraph:
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> tuple[()]:
return ()
monkeypatch.setitem(SOURCE_FACTORIES, 'sourcegraph', lambda _request: EmptySourcegraph())
outcome = await run_source(SourceRequest('sourcegraph', 'example.test', 25, 0, False, True))
assert outcome.execution.status == 'completed'
assert outcome.execution.stop_reason == 'no-results'
assert outcome.execution.result_count == 0
@pytest.mark.parametrize('source', ['builtwith', 'hudsonrock', 'shodan'])
@pytest.mark.parametrize(
('reported_status', 'reported_reason', 'has_results', 'expected_count'),
[
('completed', None, False, 0),
('partial', 'provider-partial', True, 1),
('failed', 'provider-failure', False, 0),
('rate-limited', 'http-429', False, 0),
],
)
@pytest.mark.asyncio
async def test_special_sources_share_runner_outcome_semantics(
monkeypatch: pytest.MonkeyPatch,
source: str,
reported_status: str,
reported_reason: str | None,
has_results: bool,
expected_count: int,
) -> None:
class FakeSpecialSource:
execution_status = reported_status
stop_reason = reported_reason
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'partial.example.test'} if has_results else set()
async def get_emails(self) -> set[str]:
return set()
async def get_ips(self) -> set[str]:
return set()
async def get_urls(self) -> set[str]:
return set()
async def get_frameworks(self) -> set[str]:
return set()
async def get_languages(self) -> set[str]:
return set()
async def get_servers(self) -> set[str]:
return set()
async def get_cms(self) -> set[str]:
return set()
async def get_analytics(self) -> set[str]:
return set()
async def get_infostealers(self) -> list[dict[str, object]]:
return []
async def get_shodan_hosts(self) -> tuple[()]:
return ()
monkeypatch.setitem(SOURCE_FACTORIES, source, lambda _request: FakeSpecialSource())
outcome = await run_source(SourceRequest(source, 'example.test', 25, 0, False, True))
assert outcome.execution.status == reported_status
assert outcome.execution.stop_reason == (reported_reason or 'no-results')
assert outcome.execution.result_count == expected_count
assert {observation.source for observation in outcome.observations} == ({source} if has_results else set())
@pytest.mark.asyncio
async def test_runner_collects_builtwith_compatibility_observations(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeBuiltWith:
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'app.example.test'}
async def get_urls(self) -> set[str]:
return {'https://app.example.test'}
async def get_frameworks(self) -> set[str]:
return {'Django'}
async def get_languages(self) -> set[str]:
return {'Python'}
async def get_servers(self) -> set[str]:
return {'nginx'}
async def get_cms(self) -> set[str]:
return {'Wagtail'}
async def get_analytics(self) -> set[str]:
return {'Plausible'}
monkeypatch.setitem(SOURCE_FACTORIES, 'builtwith', lambda _request: FakeBuiltWith())
outcome = await run_source(SourceRequest('builtwith', 'example.test', 25, 0, False, True))
assert {(item.kind, item.value) for item in outcome.observations} == {
('analytics', 'Plausible'),
('cms', 'Wagtail'),
('framework', 'Django'),
('hostname', 'app.example.test'),
('language', 'Python'),
('server', 'nginx'),
('url', 'https://app.example.test'),
}
assert outcome.execution.result_count == 7
@pytest.mark.asyncio
async def test_runner_collects_hudson_rock_infostealers(monkeypatch: pytest.MonkeyPatch) -> None:
infostealer = {'type': 'employee', 'url': 'https://legacy.example.test'}
class FakeHudsonRock:
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'portal.example.test'}
async def get_emails(self) -> set[str]:
return {'user@example.test'}
async def get_ips(self) -> set[str]:
return set()
async def get_urls(self) -> set[str]:
return {'https://portal.example.test'}
async def get_infostealers(self) -> list[dict[str, object]]:
return [infostealer]
monkeypatch.setitem(SOURCE_FACTORIES, 'hudsonrock', lambda _request: FakeHudsonRock())
outcome = await run_source(SourceRequest('hudsonrock', 'example.test', 25, 0, False, True))
assert {(item.kind, item.value) for item in outcome.observations} == {
('email', 'user@example.test'),
('hostname', 'portal.example.test'),
('infostealer', '{"type":"employee","url":"https://legacy.example.test"}'),
}
assert outcome.execution.result_count == 3
@pytest.mark.asyncio
async def test_runner_keeps_only_asn_attributions_backed_by_accepted_observations(
monkeypatch: pytest.MonkeyPatch,
) -> None:
collected_at = datetime.now(UTC)
class FakeOnyphe:
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
raise AssertionError('no-hosts must not read hostname results')
async def get_ips(self) -> set[str]:
return {'192.0.2.1'}
async def get_asns(self) -> set[str]:
return {'AS64500'}
async def get_asn_attributions(self) -> set[AsnAttributionObservation]:
return {
AsnAttributionObservation('source', 'onyphe', 'AS64500', 'Accepted Org', 'ip', '192.0.2.1', collected_at),
AsnAttributionObservation('source', 'onyphe', 'AS64501', 'Wrong ASN', 'ip', '192.0.2.1', collected_at),
AsnAttributionObservation(
'source', 'onyphe', 'AS64500', 'Excluded Host', 'hostname', 'host.example.test', collected_at
),
}
monkeypatch.setitem(SOURCE_FACTORIES, 'onyphe', lambda _request: FakeOnyphe())
outcome = await run_source(SourceRequest('onyphe', 'example.test', 25, 0, False, False))
assert len(outcome.asn_attributions) == 1
assert outcome.asn_attributions[0].organization_label == 'Accepted Org'
@pytest.mark.asyncio
async def test_runner_reports_invalid_asn_as_partial_and_keeps_prior_ip(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeOnyphe:
async def process(self, _proxy: bool) -> None:
return None
async def get_ips(self) -> set[str]:
return {'192.0.2.1'}
async def get_asns(self) -> set[str]:
return {'not-an-asn'}
monkeypatch.setitem(SOURCE_FACTORIES, 'onyphe', lambda _request: FakeOnyphe())
outcome = await run_source(SourceRequest('onyphe', 'example.test', 25, 0, False, False))
assert outcome.execution.status == 'partial'
assert outcome.execution.error_type == 'ValueError'
assert outcome.observations == (ResultObservation('onyphe', 'ip', '192.0.2.1'),)
@pytest.mark.asyncio
async def test_runner_collects_url_before_later_asn_getter_failure(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeZoomEye:
async def process(self, _proxy: bool) -> None:
return None
async def get_emails(self) -> set[str]:
return set()
async def get_ips(self) -> set[str]:
return set()
async def get_people(self) -> set[str]:
return set()
async def get_urls(self) -> set[str]:
return {'https://portal.example.test'}
async def get_asns(self) -> set[str]:
raise RuntimeError('asn getter failed')
monkeypatch.setitem(SOURCE_FACTORIES, 'zoomeye', lambda _request: FakeZoomEye())
outcome = await run_source(SourceRequest('zoomeye', 'example.test', 25, 0, False, False))
assert outcome.execution.status == 'partial'
assert outcome.execution.error_type == 'RuntimeError'
assert outcome.observations == (ResultObservation('zoomeye', 'url', 'https://portal.example.test'),)
@pytest.mark.asyncio
async def test_source_jobs_use_a_clamped_worker_pool_and_isolate_failures(
monkeypatch: pytest.MonkeyPatch,
) -> None:
active = 0
peak = 0
three_active = asyncio.Event()
task_names: set[str] = set()
class GatedAdapter:
def __init__(self, source: str) -> None:
self.source = source
async def process(self, _proxy: bool) -> None:
nonlocal active, peak
task = asyncio.current_task()
assert task is not None
task_names.add(task.get_name())
active += 1
peak = max(peak, active)
if active == 3:
three_active.set()
await three_active.wait()
await asyncio.sleep(0)
active -= 1
if self.source == 'apis-guru':
raise RuntimeError('provider failed')
async def get_hostnames(self) -> set[str]:
return {f'{self.source}.example.test'}
source_names = ('apis-guru', 'sourcegraph', 'crtsh', 'crt-name')
for source in source_names:
monkeypatch.setitem(SOURCE_FACTORIES, source, lambda _request, source=source: GatedAdapter(source))
jobs = tuple(SourceJob(SourceRequest(source, 'example.test', 25, 0, False, True)) for source in source_names)
outcomes = await run_source_jobs(jobs, workers=3)
assert peak == 3
assert task_names == {'source-worker:0', 'source-worker:1', 'source-worker:2'}
assert [outcome.execution.source for outcome in outcomes] == list(source_names)
assert outcomes[0].execution.status == 'partial'
assert outcomes[0].execution.error_type == 'RuntimeError'
assert all(outcome.execution.status == 'completed' for outcome in outcomes[1:])
assert not [task for task in asyncio.all_tasks() if task.get_name().startswith('source-worker:') and not task.done()]
@pytest.mark.parametrize('workers', [1, 3, 8])
@pytest.mark.asyncio
async def test_source_worker_count_does_not_change_completed_sources_or_results(
monkeypatch: pytest.MonkeyPatch,
workers: int,
) -> None:
starts: list[str] = []
task_names: set[str] = set()
class CompleteAdapter:
def __init__(self, source: str) -> None:
self.source = source
async def process(self, _proxy: bool) -> None:
task = asyncio.current_task()
assert task is not None
task_names.add(task.get_name())
starts.append(self.source)
await asyncio.sleep(0)
async def get_hostnames(self) -> set[str]:
return {f'{self.source}.example.test'}
source_names = ('apis-guru', 'sourcegraph', 'crtsh', 'crt-name')
for source in source_names:
monkeypatch.setitem(SOURCE_FACTORIES, source, lambda _request, source=source: CompleteAdapter(source))
jobs = tuple(SourceJob(SourceRequest(source, 'example.test', 25, 0, False, True)) for source in source_names)
outcomes = await run_source_jobs(jobs, workers=workers)
assert sorted(starts) == sorted(source_names)
assert [outcome.execution.source for outcome in outcomes] == list(source_names)
assert [outcome.execution.result_count for outcome in outcomes] == [1, 1, 1, 1]
assert len(task_names) == min(workers, len(jobs))
@pytest.mark.asyncio
async def test_cancelled_source_commits_immutable_partial_outcome_then_propagates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
committed: list[SourceOutcome] = []
cancellation = asyncio.CancelledError()
class CancelledSourcegraph:
async def process(self, _proxy: bool) -> None:
raise cancellation
async def get_hostnames(self) -> set[str]:
return {'partial.example.test'}
monkeypatch.setitem(SOURCE_FACTORIES, 'sourcegraph', lambda _request: CancelledSourcegraph())
with pytest.raises(asyncio.CancelledError) as raised:
await run_source(
SourceRequest('sourcegraph', 'example.test', 25, 0, False, True),
commit_cancelled=committed.append,
)
assert raised.value is cancellation
assert len(committed) == 1
outcome = committed[0]
assert outcome.execution.status == 'partial'
assert outcome.execution.error_type == 'CancelledError'
assert outcome.execution.stop_reason == 'cancelled'
assert outcome.observations == (ResultObservation('sourcegraph', 'hostname', 'partial.example.test'),)
with pytest.raises(FrozenInstanceError):
outcome.observations = () # type: ignore[misc]
@pytest.mark.asyncio
async def test_child_cancellation_promptly_cleans_blocking_sibling_and_preserves_original(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sibling_started = asyncio.Event()
sibling_cancelled = asyncio.Event()
cancellation = asyncio.CancelledError('source cancelled')
class CancellingAdapter:
async def process(self, _proxy: bool) -> None:
await sibling_started.wait()
raise cancellation
async def get_hostnames(self) -> set[str]:
return set()
class BlockingAdapter:
async def process(self, _proxy: bool) -> None:
sibling_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
sibling_cancelled.set()
raise
async def get_hostnames(self) -> set[str]:
return set()
monkeypatch.setitem(SOURCE_FACTORIES, 'sourcegraph', lambda _request: CancellingAdapter())
monkeypatch.setitem(SOURCE_FACTORIES, 'crtsh', lambda _request: BlockingAdapter())
jobs = tuple(SourceJob(SourceRequest(source, 'example.test', 25, 0, False, True)) for source in ('crtsh', 'sourcegraph'))
with pytest.raises(asyncio.CancelledError) as raised:
async with asyncio.timeout(0.5):
await run_source_jobs(jobs)
assert raised.value is cancellation
assert sibling_cancelled.is_set()
@pytest.mark.asyncio
async def test_parent_cancellation_commits_active_jobs_and_cleans_structured_tasks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
started = asyncio.Event()
started_count = 0
committed: list[SourceOutcome] = []
class BlockingAdapter:
def __init__(self, source: str) -> None:
self.source = source
async def process(self, _proxy: bool) -> None:
nonlocal started_count
started_count += 1
if started_count == 3:
started.set()
await asyncio.Event().wait()
async def get_hostnames(self) -> set[str]:
return {f'{self.source}.example.test'}
source_names = ('sourcegraph', 'crtsh', 'crt-name', 'apis-guru')
for source in source_names:
monkeypatch.setitem(SOURCE_FACTORIES, source, lambda _request, source=source: BlockingAdapter(source))
jobs = tuple(SourceJob(SourceRequest(source, 'example.test', 25, 0, False, True)) for source in source_names)
task = asyncio.create_task(run_source_jobs(jobs, commit=committed.append))
await started.wait()
task.cancel('parent-marker')
with pytest.raises(asyncio.CancelledError) as raised:
await task
assert raised.value.args == ('parent-marker',)
assert {outcome.execution.source for outcome in committed} == set(source_names)
assert all(outcome.execution.stop_reason == 'cancelled' for outcome in committed)
assert not [task for task in asyncio.all_tasks() if task.get_name().startswith('source-worker:') and not task.done()]