diff --git a/README.md b/README.md index 130ab94..640e1b3 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Export to CSV: - `--userenum`: Extract usernames via CUCM User Data Services (UDS) API (paginates the full directory) and harvest the full directory records (names incl. nickname, phone/home/mobile/pager numbers, email, directory URI, MS URI, department, title, manager, UUID) into the `uds_directory` table; always writes `cucm_directory.csv` (override with `--directory-outfile`) - `--directory`: Harvest the unauthenticated CUCM corporate directory from `/cucm-uds/users` without any device probing or config downloads — requires `-H`; always writes `cucm_directory.csv` (override with `--directory-outfile`), prints a console table, and stores to `uds_directory` unless `--no-db` - `--directory-outfile FILENAME`: Override the default CSV output path for `--directory` and `--userenum` (default: `cucm_directory.csv`) -- `--servers`: Enumerate CUCM cluster members (hostnames + IPs) via UDS `/cucm-uds/servers` — requires `-H` +- `--servers`: Enumerate CUCM cluster members via UDS `/cucm-uds/servers` — requires `-H`. UDS returns a hostname per cluster member and nothing else, so addresses and Publisher/Subscriber roles are not available from this endpoint - `--http`: Use HTTP (port 6970) as the primary config download protocol with TFTP fallback (default: TFTP first, HTTP fallback) - `--uds-port PORT`: Override the CUCM UDS API HTTPS port for `--userenum`, `--directory`, and `--servers` (default: 8443) - `--spray`: Password-spray the UDS API (requires `-H`; mutually exclusive with `--brute-mac`) diff --git a/src/seeyoucm_thief/thief.py b/src/seeyoucm_thief/thief.py index 32ecc4a..b105550 100644 --- a/src/seeyoucm_thief/thief.py +++ b/src/seeyoucm_thief/thief.py @@ -386,8 +386,9 @@ def get_version(cucm_host, port=UDS_PORT, timeout=10): if not cucm_host: return None if _TEST_MODE: - return {'version': '12.5.1-TEST', 'prefix': '11.0(1)', - 'usersAuthRequired': False, 'port': port} + return {'version': '12.5.1-TEST', 'schemaVersion': '10.0.0', + 'usersAuthRequired': False, 'upgradeInProgress': False, + 'port': port} url = f'https://{cucm_host}:{port}/cucm-uds/version' dbg(f'UDS GET {url} (timeout={timeout}s)') @@ -401,11 +402,25 @@ def get_version(cucm_host, port=UDS_PORT, timeout=10): dbg(f'UDS version non-200 body (first 300 chars): {resp.text[:300]!r}') return None + # Per the v14 schema (version.get.xsd) the whole response is: + # <- version attr = UDS schema + # 14.0.1 <- element = CUCM version + # + # bool + # bool + # + # + # There are no other elements; is mandatory on 14 but absent + # before 11.5(1), so both flags stay unset on older clusters. info = {} - for field in ('version', 'prefix'): - m = re.search(rf'<{field}>([^<]+)', resp.text) - if m: - info[field] = m.group(1).strip() + m = re.search(r'([^<]+)', resp.text) + if m: + info['version'] = m.group(1).strip() + # The version= attribute on the wrapper is the UDS schema version, which is + # distinct from the CUCM version in the element. + m = re.search(r']*\bversion="([^"]+)"', resp.text) + if m: + info['schemaVersion'] = m.group(1).strip() # UDS advertises whether /users needs Basic auth via this flag (present # since 11.5). When true, unauthenticated --userenum/--directory/--spray # will come back empty even though the version probe succeeded, so surface @@ -414,6 +429,12 @@ def get_version(cucm_host, port=UDS_PORT, timeout=10): resp.text, re.IGNORECASE) if auth_m: info['usersAuthRequired'] = auth_m.group(1).lower() == 'true' + # A cluster mid-upgrade can return partial or inconsistent directory data, + # so results captured now should not be trusted as a complete baseline. + upg_m = re.search(r'\s*(true|false)\s*', + resp.text, re.IGNORECASE) + if upg_m: + info['upgradeInProgress'] = upg_m.group(1).lower() == 'true' if info: info['port'] = port return info or None @@ -562,11 +583,36 @@ def _add(name): return filenames +def _parse_uds_users_wrapper(body): + """Extract the paging attributes from the wrapper element. + + The v14 UDS schema (users.get.xsd) declares start, requestedCount, + returnedCount and totalCount as use="required", so a conformant response + always carries all four. Returns a dict of the ones actually present, so a + non-conformant server degrades to the -counting fallback rather + than raising.""" + m = re.search(r']*)>', body) + if not m: + return {} + attrs = m.group(1) + found = {} + for name in ('start', 'requestedCount', 'returnedCount', 'totalCount'): + am = re.search(rf'\b{name}="(-?\d+)"', attrs) + if am: + found[name] = int(am.group(1)) + return found + + def _iter_uds_user_pages(cucm_host, port=UDS_PORT, timeout=10, max_pages=10000): """Yield each /cucm-uds/users page's response body text, following UDS pagination. Shared by get_users_api and get_user_directory_api so the - page-walking logic lives in one place. Pagination 'item count' is measured by - occurrences (UDS paginates per user).""" + page-walking logic lives in one place. + + Paging is driven by the wrapper's own start/returnedCount + attributes: the next offset is start + returnedCount by construction, which + avoids having to assume whether UDS indexes from 0 or 1 (it is 0-based). If + a server omits those attributes, fall back to counting + occurrences and offsetting by the running total.""" base = f'https://{cucm_host}:{port}/cucm-uds/users' seen_urls = set() next_url = base @@ -598,20 +644,37 @@ def _iter_uds_user_pages(cucm_host, port=UDS_PORT, timeout=10, max_pages=10000): dbg(f'UDS empty page body (first 300 chars): {resp.text[:300]!r}') break + wrapper = _parse_uds_users_wrapper(resp.text) + yield resp.text collected += page_count - if total is None: - total_match = re.search(r']*\btotalCount="(\d+)"', resp.text) \ - or re.search(r'(\d+)', resp.text) - if total_match: - total = int(total_match.group(1)) - dbg(f'UDS server reports totalCount={total}') + if total is None and 'totalCount' in wrapper: + total = wrapper['totalCount'] + dbg(f'UDS server reports totalCount={total}') if total is not None and collected >= total: break - next_url = _uds_next_link(resp.text, base, collected + 1) + # Prefer the server's own offset arithmetic. requestedCount > + # returnedCount means the server clamped the page below what was asked + # for (UserSearchLimit), which is normal and not an error. + if 'start' in wrapper and 'returnedCount' in wrapper: + if wrapper['returnedCount'] <= 0: + dbg('UDS returnedCount=0; stopping pagination') + break + requested = wrapper.get('requestedCount') + if requested is not None and requested > wrapper['returnedCount']: + dbg(f'UDS clamped page size: requested {requested}, ' + f'returned {wrapper["returnedCount"]}') + next_start = wrapper['start'] + wrapper['returnedCount'] + else: + # Non-conformant server: offset by what we have actually collected. + # start is 0-based, so the next page begins at `collected`, not + # `collected + 1`. + next_start = collected + + next_url = _uds_next_link(base, next_start) if not next_url: dbg('UDS no next-page link found; stopping pagination') break @@ -665,7 +728,11 @@ def parse_uds_directory(xml_body): continue record = {'username': name_match.group(1).strip()} for key, tag in _UDS_DIRECTORY_FIELDS: - m = re.search(rf'<{tag}>([^<]*)', block) + # Tolerate attributes on the element: users.get.xsd gives + # an optional exist="true|false" attribute, and a + # bare match silently yields '' when CUCM sets it. \b keeps + # from matching a longer tag that merely starts with "id". + m = re.search(rf'<{tag}\b[^>]*>([^<]*)', block) record[key] = m.group(1).strip() if m else '' records.append(record) return records @@ -691,7 +758,7 @@ def get_user_directory_api(cucm_host, port=UDS_PORT, timeout=10, max_pages=10000 def get_servers_api(cucm_host, port=UDS_PORT, timeout=10): if _TEST_MODE: - return [{'hostName': 'cucm-pub.test', 'ipv4Address': '10.0.0.1', 'serverType': 'Publisher'}] + return [{'hostName': 'cucm-pub.test'}, {'hostName': 'cucm-sub1.test'}] url = f'https://{cucm_host}:{port}/cucm-uds/servers' dbg(f'UDS GET {url} (timeout={timeout}s)') @@ -705,36 +772,31 @@ def get_servers_api(cucm_host, port=UDS_PORT, timeout=10): dbg(f'UDS servers non-200 body (first 300 chars): {resp.text[:300]!r}') return [] + # The v14 schema (servers.get.xsd) declares as type="xs:string" — + # a bare hostname with no child elements, and the schema permits none. This + # is the only shape UDS has ever published (10.0(1) through 14), so there is + # no version branching to do here. UDS exposes no addresses and no server + # role: resolving a hostname to an IP is a DNS lookup on our side, and + # Publisher/Subscriber role requires AXL. servers = [] for block in re.findall(r']*>(.*?)', resp.text, re.DOTALL): - srv = {} - for field in ('hostName', 'ipv4Address', 'ipv6Address', 'serverType'): - m = re.search(rf'<{field}>([^<]+)', block) - if m: - srv[field] = m.group(1).strip() - if not srv: - # Newer UDS (15.x+) returns the hostname as plain text inside - # ... with no child elements. - text = re.sub(r'<[^>]+>', '', block).strip() - if text: - srv['hostName'] = text - if srv: - servers.append(srv) + host = block.strip() + if host: + servers.append({'hostName': host}) dbg(f'UDS parsed {len(servers)} server entries from cluster topology') return servers -def _uds_next_link(body, base_url, fallback_start): - # HATEOAS variants seen in CUCM UDS responses - m = re.search(r']*\brel="next"[^>]*\bhref="([^"]+)"', body, re.IGNORECASE) - if m: - return m.group(1) - m = re.search(r'([^<]+)', body, re.IGNORECASE) - if m: - return m.group(1).strip() - # Fallback: try ?start=N (most common Cisco UDS pagination param) +def _uds_next_link(base_url, start): + """Build the URL for the UDS /users page beginning at offset ``start``. + + UDS exposes no HATEOAS paging links — the v14 schema's wrapper + permits only the uri/version/start/requestedCount/returnedCount/totalCount + attributes and children, with no or + element in any published release. Client-side offset arithmetic on the + 0-based ``start`` parameter is the only paging mechanism available.""" sep = '&' if '?' in base_url else '?' - return f'{base_url}{sep}start={fallback_start}' + return f'{base_url}{sep}start={start}' def log_uds_usernames_to_db(cucm_host, usernames, db_file='thief.db'): @@ -2033,6 +2095,11 @@ def log_cluster_servers_to_db(queried_host, servers, db_file='thief.db'): inserted = 0 for srv in servers: hostname = srv.get('hostName') or '' + # UDS /cucm-uds/servers returns only a hostname per (v14 + # schema: type="xs:string"). ipv4/ipv6/server_type are retained as + # columns for a future DNS-resolution or AXL enrichment step and for + # compatibility with existing thief.db files, but nothing UDS + # returns can populate them. ipv4 = srv.get('ipv4Address') or '' ipv6 = srv.get('ipv6Address') or '' server_type = srv.get('serverType') or '' @@ -2420,10 +2487,13 @@ def display_database_summary(db_file='thief.db', cucm_filter=None): if cluster_servers: print(f'\n\033[1m[+] CUCM CLUSTER SERVERS ({len(cluster_servers)} total)\033[0m') print("-"*70) - print(f'{"Queried Host":<24} {"Hostname":<30} {"IPv4":<16}') + # No address column: UDS /cucm-uds/servers returns only a hostname + # per cluster member, so ipv4/ipv6/server_type are always empty for + # rows this tool writes (see log_cluster_servers_to_db). + print(f'{"Queried Host":<24} {"Hostname":<30} {"Discovered":<20}') print("-"*70) for queried, hostname, ipv4, ipv6, srv_type, timestamp in cluster_servers: - print(f'{queried:<24} {(hostname or ""):<30} {(ipv4 or ""):<16}') + print(f'{queried:<24} {(hostname or ""):<30} {(timestamp or ""):<20}') if spray_hits: print('\n=== UDS Spray Hits ===') @@ -2929,10 +2999,14 @@ def main(): if version_info: uds_port = version_info.get('port', args.uds_port) v = version_info.get('version', 'unknown') - p = version_info.get('prefix') - print(f'[+] CUCM {CUCM_host} version: {v}' + (f' (prefix {p})' if p else '')) + sv = version_info.get('schemaVersion') + print(f'[+] CUCM {CUCM_host} version: {v}' + (f' (UDS schema {sv})' if sv else '')) if uds_port != args.uds_port: print(f'[*] UDS answered on :{uds_port} (Contact Search Authentication likely enabled); using it for this run') + if version_info.get('upgradeInProgress'): + print('[!] CUCM reports an upgrade in progress (upgradeInProgress=true).') + print(' UDS results may be incomplete or inconsistent; treat this run as ' + 'provisional and re-enumerate once the upgrade finishes.') if version_info.get('usersAuthRequired'): print('[!] This cluster requires authentication for the UDS /users resource ' '(usersResourceAuthEnabled=true).') @@ -2952,18 +3026,7 @@ def main(): quit(0) print(f'[+] Discovered {len(servers)} cluster member(s):') for srv in servers: - host = srv.get('hostName', '?') - ipv4 = srv.get('ipv4Address', '') - ipv6 = srv.get('ipv6Address', '') - srv_type = srv.get('serverType', '') - parts = [host] - if ipv4: - parts.append(f'({ipv4})') - if ipv6: - parts.append(f'[v6: {ipv6}]') - if srv_type: - parts.append(f'<{srv_type}>') - print(' ' + ' '.join(parts)) + print(' ' + srv.get('hostName', '?')) if not no_db: inserted = log_cluster_servers_to_db(CUCM_host, servers, db_file) print(f'[+] Logged {inserted} new cluster server entry/entries to database') diff --git a/tests/test_brute_host_prefixes.py b/tests/test_brute_host_prefixes.py index 301f507..1ac7321 100644 --- a/tests/test_brute_host_prefixes.py +++ b/tests/test_brute_host_prefixes.py @@ -88,7 +88,7 @@ def test_servers_feature_still_probes_uds_version(monkeypatch, tmp_path): """UDS features must keep running the version probe.""" calls = [] monkeypatch.setattr(thief, 'get_version', - lambda *a, **kw: calls.append(kw) or {'version': '14.0', 'prefix': None}) + lambda *a, **kw: calls.append(kw) or {'version': '14.0'}) monkeypatch.setattr(thief, 'get_servers_api', lambda *a, **kw: []) db_file = str(tmp_path / 'thief.db') thief.init_database(db_file) diff --git a/tests/test_spray.py b/tests/test_spray.py index c06394f..26cbe99 100644 --- a/tests/test_spray.py +++ b/tests/test_spray.py @@ -528,7 +528,7 @@ def fake_run_spray(**kwargs): called.update(kwargs) monkeypatch.setattr(thief, 'run_spray', fake_run_spray) - monkeypatch.setattr(thief, 'get_version', lambda *a, **kw: {'version': '15.0', 'prefix': None}) + monkeypatch.setattr(thief, 'get_version', lambda *a, **kw: {'version': '15.0'}) db = tmp_path / "thief.db" monkeypatch.setattr('sys.argv', [ 'thief', '-H', '1.2.3.4', '--spray', @@ -557,7 +557,7 @@ def fake_run_spray(**kwargs): called.update(kwargs) monkeypatch.setattr(thief, 'run_spray', fake_run_spray) - monkeypatch.setattr(thief, 'get_version', lambda *a, **kw: {'version': '15.0', 'prefix': None}) + monkeypatch.setattr(thief, 'get_version', lambda *a, **kw: {'version': '15.0'}) db = tmp_path / "thief.db" monkeypatch.setattr('sys.argv', [ 'thief', '-H', '1.2.3.4', '--spray', '-P', str(pw_file), diff --git a/tests/test_uds_hardening.py b/tests/test_uds_hardening.py index 0ed381f..52e0ec1 100644 --- a/tests/test_uds_hardening.py +++ b/tests/test_uds_hardening.py @@ -13,10 +13,17 @@ def _disable_test_mode(monkeypatch): def _version_xml(auth=None): - body = "14.0.114.0(1)" + """Shaped per the official v14 version.get.xsd: a + wrapper whose version= attribute is the UDS schema version, a + element holding the CUCM version, and the capability flags nested under + .""" + caps = '' if auth is not None: - body += f"{'true' if auth else 'false'}" - return body + "" + caps = ('' + f"{'true' if auth else 'false'}" + '') + return ('' + f'14.0.1{caps}') def _resp(text, status=200): diff --git a/tests/test_uds_schema_conformance.py b/tests/test_uds_schema_conformance.py new file mode 100644 index 0000000..5106a29 --- /dev/null +++ b/tests/test_uds_schema_conformance.py @@ -0,0 +1,369 @@ +"""Tests pinning UDS parsing to the official Cisco v14 XML schemas +(UDS-xsd-14.zip, developer.cisco.com/site/user-data-services/downloads/schemas/). + +The schemas are the authoritative source here — Cisco's prose docs stop at +12.5(1) and disagree with themselves in places. Element/attribute shapes +asserted below are quoted from the XSDs in the docstrings. +""" +import re +from unittest.mock import MagicMock, patch + +import pytest + +from seeyoucm_thief import thief + + +@pytest.fixture(autouse=True) +def _disable_test_mode(monkeypatch): + monkeypatch.setattr(thief, '_TEST_MODE', False) + + +def _resp(text, status=200): + r = MagicMock() + r.status_code = status + r.text = text + r.content = text.encode() + return r + + +def _users_page(users, start, total, requested=None, returned=None): + """A schema-conformant page. + + users.get.xsd declares start/requestedCount/returnedCount/totalCount as + use="required" on the wrapper. + """ + returned = len(users) if returned is None else returned + requested = returned if requested is None else requested + body = ''.join( + f'id-{u}' + f'{u}' + for u in users + ) + return (f'{body}') + + +# --------------------------------------------------------------------------- +# Pagination (#33): start is 0-based; drive paging off the wrapper attributes +# --------------------------------------------------------------------------- + +def test_next_page_offset_comes_from_start_plus_returned_count(): + """A full first page of 64 must be followed by start=64, not start=65.""" + page1 = _users_page([f'u{i}' for i in range(64)], start=0, total=100) + page2 = _users_page([f'u{i}' for i in range(64, 100)], start=64, total=100) + urls = [] + + def side_effect(url, **kwargs): + urls.append(url) + return _resp(page1 if len(urls) == 1 else page2) + + with patch.object(thief.requests, 'get', side_effect=side_effect): + users = thief.get_users_api('cucm.example.com', 8443) + + assert urls[1] == 'https://cucm.example.com:8443/cucm-uds/users?start=64' + assert len(users) == 100 + + +def test_no_user_is_skipped_across_the_page_boundary(): + """The off-by-one in #33 silently dropped the first user of every page + after the first. + + The fake server honours the requested ``start`` offset by slicing the + directory, exactly as a real CUCM does. That is what makes this a real + regression test: an off-by-one in the requested offset makes the server + genuinely skip a user, so the assertion on the collected set fails on its + own rather than relying on the URL assertions below. + """ + expected = [f'user{i:03d}' for i in range(130)] + page_size = 64 + seen = [] + + def side_effect(url, **kwargs): + seen.append(url) + m = re.search(r'[?&]start=(\d+)', url) + start = int(m.group(1)) if m else 0 + window = expected[start:start + page_size] + return _resp(_users_page(window, start=start, total=len(expected), + requested=page_size)) + + with patch.object(thief.requests, 'get', side_effect=side_effect): + users = thief.get_users_api('cucm.example.com', 8443) + + assert users == expected + assert seen[1].endswith('?start=64') + assert seen[2].endswith('?start=128') + + +def test_pagination_tolerates_a_server_clamping_the_page_size(): + """requestedCount > returnedCount means UserSearchLimit clamped the page. + That is normal; paging must follow returnedCount, not requestedCount.""" + page1 = _users_page(['a', 'b'], start=0, total=4, requested=64, returned=2) + page2 = _users_page(['c', 'd'], start=2, total=4, requested=64, returned=2) + urls = [] + + def side_effect(url, **kwargs): + urls.append(url) + return _resp(page1 if len(urls) == 1 else page2) + + with patch.object(thief.requests, 'get', side_effect=side_effect): + users = thief.get_users_api('cucm.example.com', 8443) + + assert urls[1].endswith('?start=2') + assert users == ['a', 'b', 'c', 'd'] + + +def test_pagination_falls_back_to_collected_count_without_wrapper_attrs(): + """A non-conformant server that omits start/returnedCount still pages, and + the fallback offset is 0-based (collected, not collected + 1).""" + page1 = 'a' \ + 'b' + page2 = 'c' + urls = [] + + def side_effect(url, **kwargs): + urls.append(url) + return _resp(page1 if len(urls) == 1 else page2) + + with patch.object(thief.requests, 'get', side_effect=side_effect): + users = thief.get_users_api('cucm.example.com', 8443) + + assert urls[1].endswith('?start=2') + assert users == ['a', 'b', 'c'] + + +def test_pagination_stops_when_returned_count_is_zero(): + page1 = _users_page(['a'], start=0, total=99, returned=0) + calls = [] + + def side_effect(url, **kwargs): + calls.append(url) + return _resp(page1) + + with patch.object(thief.requests, 'get', side_effect=side_effect): + users = thief.get_users_api('cucm.example.com', 8443) + + assert len(calls) == 1 + assert users == ['a'] + + +def test_uds_next_link_appends_start_to_a_bare_base(): + assert thief._uds_next_link('https://c:8443/cucm-uds/users', 64) == \ + 'https://c:8443/cucm-uds/users?start=64' + + +def test_uds_next_link_uses_ampersand_when_query_already_present(): + assert thief._uds_next_link('https://c:8443/cucm-uds/users?max=500', 64) == \ + 'https://c:8443/cucm-uds/users?max=500&start=64' + + +def test_parse_uds_users_wrapper_reads_all_four_required_attrs(): + w = thief._parse_uds_users_wrapper(_users_page(['a'], start=8, total=9)) + assert w == {'start': 8, 'requestedCount': 1, 'returnedCount': 1, + 'totalCount': 9} + + +def test_parse_uds_users_wrapper_returns_empty_without_a_wrapper(): + assert thief._parse_uds_users_wrapper('') == {} + + +# --------------------------------------------------------------------------- +# /cucm-uds/servers (#36): is type="xs:string" +# --------------------------------------------------------------------------- + +SERVERS_XML = ( + '' + 'cucm-pub.example.com' + 'cucm-sub1.example.com' + '10.0.0.9' + '' +) + + +def test_servers_parsed_as_plain_text_hostnames(monkeypatch): + """servers.get.xsd: — a bare + hostname, no child elements, on every release 10.0(1) through 14.""" + monkeypatch.setattr(thief.requests, 'get', lambda *a, **kw: _resp(SERVERS_XML)) + servers = thief.get_servers_api('cucm', port=8443) + assert servers == [ + {'hostName': 'cucm-pub.example.com'}, + {'hostName': 'cucm-sub1.example.com'}, + {'hostName': '10.0.0.9'}, + ] + + +def test_servers_parse_tolerates_pretty_printed_whitespace(monkeypatch): + xml = ('\n' + ' \n cucm-pub.example.com\n \n' + '\n') + monkeypatch.setattr(thief.requests, 'get', lambda *a, **kw: _resp(xml)) + assert thief.get_servers_api('cucm', port=8443) == \ + [{'hostName': 'cucm-pub.example.com'}] + + +def test_servers_parse_skips_empty_server_elements(monkeypatch): + xml = ('' + 'real.example.com') + monkeypatch.setattr(thief.requests, 'get', lambda *a, **kw: _resp(xml)) + assert thief.get_servers_api('cucm', port=8443) == \ + [{'hostName': 'real.example.com'}] + + +def test_servers_result_never_carries_axl_style_fields(monkeypatch): + """hostName/ipv4Address/ipv6Address/serverType were AXL vocabulary that UDS + never returns. Only hostName should ever be present.""" + monkeypatch.setattr(thief.requests, 'get', lambda *a, **kw: _resp(SERVERS_XML)) + for srv in thief.get_servers_api('cucm', port=8443): + assert set(srv) == {'hostName'} + + +def test_servers_empty_on_non_200(monkeypatch): + monkeypatch.setattr(thief.requests, 'get', + lambda *a, **kw: _resp('nope', status=401)) + assert thief.get_servers_api('cucm', port=8443) == [] + + +# --------------------------------------------------------------------------- +# /cucm-uds/version (#35, #37) +# --------------------------------------------------------------------------- + +def _version_xml(auth=None, upgrading=None, schema='10.0.0', cucm='14.0.1'): + """A schema-conformant version.get.xsd response.""" + caps = '' + if auth is not None: + caps += f"{'true' if auth else 'false'}" + if upgrading is not None: + caps += f"{'true' if upgrading else 'false'}" + if caps: + caps = f'{caps}' + return (f'{cucm}{caps}' + f'') + + +def test_version_parse_has_no_prefix_key(monkeypatch): + """version.get.xsd permits only and ; there is no + element on any release.""" + monkeypatch.setattr(thief.requests, 'get', + lambda *a, **kw: _resp(_version_xml(auth=False))) + info = thief.get_version('cucm', port=8443) + assert 'prefix' not in info + assert info['version'] == '14.0.1' + + +def test_version_parses_schema_version_from_wrapper_attribute(monkeypatch): + """The version= attribute is the UDS schema version and is distinct from + the element's CUCM version.""" + monkeypatch.setattr( + thief.requests, 'get', + lambda *a, **kw: _resp(_version_xml(auth=False, schema='10.0.0', cucm='14.0.1'))) + info = thief.get_version('cucm', port=8443) + assert info['schemaVersion'] == '10.0.0' + assert info['version'] == '14.0.1' + + +@pytest.mark.parametrize('flag', [True, False]) +def test_version_parses_upgrade_in_progress(monkeypatch, flag): + monkeypatch.setattr(thief.requests, 'get', + lambda *a, **kw: _resp(_version_xml(auth=False, upgrading=flag))) + assert thief.get_version('cucm', port=8443)['upgradeInProgress'] is flag + + +def test_version_omits_upgrade_flag_when_capabilities_absent(monkeypatch): + """ is mandatory in v14 but absent before 11.5(1). A missing + flag must stay absent rather than defaulting to a value.""" + monkeypatch.setattr(thief.requests, 'get', lambda *a, **kw: _resp(_version_xml())) + info = thief.get_version('cucm', port=8443) + assert 'upgradeInProgress' not in info + assert 'usersAuthRequired' not in info + + +def test_version_parses_both_capability_flags_together(monkeypatch): + monkeypatch.setattr( + thief.requests, 'get', + lambda *a, **kw: _resp(_version_xml(auth=True, upgrading=True))) + info = thief.get_version('cucm', port=8443) + assert info['usersAuthRequired'] is True + assert info['upgradeInProgress'] is True + + +# --------------------------------------------------------------------------- +# main() wiring for the upgrade warning (#37) +# --------------------------------------------------------------------------- + +def test_main_warns_when_upgrade_in_progress(monkeypatch, tmp_path, capsys): + monkeypatch.setattr(thief, 'probe_uds', + lambda *a, **kw: {'version': '14.0.1', 'port': 8443, + 'upgradeInProgress': True}) + monkeypatch.setattr(thief, 'get_users_api', lambda *a, **kw: []) + db = tmp_path / 'thief.db' + thief.init_database(str(db)) + monkeypatch.setattr('sys.argv', + ['thief', '--userenum', '-H', 'cucm', '--db', str(db)]) + with pytest.raises(SystemExit): + thief.main() + out = capsys.readouterr().out + assert 'upgradeInProgress=true' in out + + +def test_main_silent_when_no_upgrade_in_progress(monkeypatch, tmp_path, capsys): + monkeypatch.setattr(thief, 'probe_uds', + lambda *a, **kw: {'version': '14.0.1', 'port': 8443, + 'upgradeInProgress': False}) + monkeypatch.setattr(thief, 'get_users_api', lambda *a, **kw: []) + db = tmp_path / 'thief.db' + thief.init_database(str(db)) + monkeypatch.setattr('sys.argv', + ['thief', '--userenum', '-H', 'cucm', '--db', str(db)]) + with pytest.raises(SystemExit): + thief.main() + assert 'upgradeInProgress' not in capsys.readouterr().out + + +def test_main_reports_uds_schema_version(monkeypatch, tmp_path, capsys): + monkeypatch.setattr(thief, 'probe_uds', + lambda *a, **kw: {'version': '14.0.1', 'port': 8443, + 'schemaVersion': '10.0.0'}) + monkeypatch.setattr(thief, 'get_users_api', lambda *a, **kw: []) + db = tmp_path / 'thief.db' + thief.init_database(str(db)) + monkeypatch.setattr('sys.argv', + ['thief', '--userenum', '-H', 'cucm', '--db', str(db)]) + with pytest.raises(SystemExit): + thief.main() + assert 'UDS schema 10.0.0' in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# parse_uds_directory: elements may legally carry attributes +# --------------------------------------------------------------------------- + +def test_directory_uri_parsed_when_exist_attribute_present(): + """users.get.xsd gives an optional exist="true|false" + attribute. A bare-tag regex silently yielded '' whenever CUCM set it, + dropping the SIP/Jabber URI from every harvested record.""" + xml = ('' + '' + 'uuid-alicealice' + 'alice@corp.example' + '') + records = thief.parse_uds_directory(xml) + assert records[0]['directory_uri'] == 'alice@corp.example' + + +def test_directory_uri_still_parsed_without_attributes(): + xml = ('bob' + 'bob@corp.example') + assert thief.parse_uds_directory(xml)[0]['directory_uri'] == 'bob@corp.example' + + +def test_directory_user_id_not_confused_by_a_longer_tag(): + """The pattern must not match a tag that merely starts with "id".""" + xml = ('' + 'not-the-id' + 'caroluuid-carol' + '') + assert thief.parse_uds_directory(xml)[0]['user_id'] == 'uuid-carol'