diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e69de29..6dcd1f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -0,0 +1,133 @@ +# Contributing + +Thanks for your interest in contributing to this project — we welcome improvements, bug +fixes, documentation updates, tests, and ideas. This document explains the most +common ways to contribute and the project's expectations for contributions. + +## Table of content + +- **Getting started** +- **Reporting issues** +- **Proposing changes (Pull Requests)** +- **Branching & commit guidance** +- **Code style & tests** +- **Pre-commit hooks & CI** +- **Security & sensitive data** +- **Code of conduct** + +## Getting started + +- Fork the repository on GitHub to your account. +- Clone your fork locally and add the upstream remote (the original repo): + + ```bash + git clone git@github.com:/cloudflare-ddns.git + cd cloudflare-ddns + git remote add upstream git@github.com:Esysc/cloudflare-ddns.git + git fetch upstream + ``` + +- Create a feature branch for your change (describe the change in the branch name): + + ```bash + git checkout -b fix/update-contributing-doc + ``` + +## Reporting issues + +- If you found a bug, missing behavior, or have a feature idea, open an issue in the + upstream repository. Provide a minimal reproduction, expected vs actual behavior, + and environment details (OS, Python version, steps to reproduce). + +## Proposing changes (Pull Requests) + +- Make changes on a branch in your fork, commit, and push the branch to your fork: + + ```bash + git add CONTRIBUTING.md + git commit -m "docs: improve contributing guide" + git push origin fix/update-contributing-doc + ``` + +- Open a Pull Request from your fork/branch to the upstream branch (`bugfix/fixes1` or `main` as appropriate). +- In the PR description, include: + - Summary of the change + - Why it's needed + - Any testing you performed + - Any migration or breaking changes + +- Keep changes focused and small where possible. Large or complex changes are easier + to review when split into smaller PRs. + +## Branching & commit guidance + +- Create short-lived branches for each logical change. +- Use clear commit messages. Follow this pattern for the first line: + + ```text + (): + + e.g. "fix(ddns): preserve record TTL when updating" + ``` + +- Types commonly used: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`. + +## Code style & tests + +- The project uses lightweight linting and formatting tools in pre-commit. +- Keep functions small and add docstrings for public functions. +- When you add functionality, include or update tests in `tests/` that exercise + the behavior. Unit tests should not make network calls; use mocking for HTTP. + +## Pre-commit hooks & CI + +- This repo includes a `.pre-commit-config.yaml`. Please run and satisfy the + pre-commit checks locally before opening a PR: + + ```bash + pip install -r requirements-dev.txt + pre-commit install + pre-commit run --all-files + ``` + +- The CI workflow runs tests on push/PR. Ensure your changes pass CI before requesting review. + +## Security & sensitive data + +- Do not commit secrets, API tokens, or credentials. Use environment variables + or GitHub Secrets for CI. +- If you discover a security issue, do not open a public issue. Instead contact + the maintainers (or the repository owner) privately so the issue can be + investigated and handled appropriately. + +## Code of Conduct + +- Be respectful, helpful, and collaborative. Report unacceptable behavior to the + maintainers. This project follows a standard code of conduct; by participating + you agree to be welcoming and professional. + +## Maintainers & review process + +- Maintainers will review PRs and may request changes. Small PRs can be merged + quickly; larger changes may take longer. +- If your PR needs more work, please address feedback and push follow-up commits + to the same branch. + +## Useful commands + +```bash +# keep fork up to date with upstream +git fetch upstream +git checkout main +git merge upstream/main + +# create branch from updated main +git checkout -b my-feature-branch + +# commit and push +git add . +git commit -m "feat: ..." +git push origin my-feature-branch +``` + +Thank you for considering a contribution — your help makes this project better! diff --git a/README.md b/README.md index d8b4b65..361c506 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ This repository contains a small DDNS updater script that updates A records in a Cloudflare zone to match the host's public IP address. -Rationale +## Rationale + --------- If you host a web server and proxy the site through Cloudflare, DNS records point to the server's public IP. When the server IP changes (reboot, DHCP, @@ -11,22 +12,28 @@ cloud VM migration), DNS can become stale. This script queries the current public IP and updates all matching A records for a given name in a Cloudflare zone so DNS stays correct. -Security --------- +## Security + +--------- + - Do NOT store secrets in source. The script reads the Cloudflare API token from the environment variable `CLOUDFLARE_API_TOKEN` (or from a token file). - Use a scoped Cloudflare API token with least privileges (DNS:Edit for the target zone). -Files ------ +## Files + +--------- + - `ddns.py` — main Python script (dry-run by default; reads token from env). - `run_ddns.sh` — helper that creates/activates a `venv` and runs `ddns.py`. -- `cron.example` — suggested crontab line. +- `cron.example` — suggested crontab line - `ddns.service` / `ddns.timer` — systemd unit and timer examples. -Quick start ------------ +## Quick start + +--------- + 1. Create a virtual environment and install dependencies: ```bash @@ -49,17 +56,20 @@ To perform a real update (be careful): DDNS_DRY_RUN=0 ./run_ddns.sh --zone example.com --name host.example.com ``` -Usage and flags ---------------- +## Usage and flags + +--------- `run_ddns.sh` accepts these options (and forwards `--zone/--name` to `ddns.py`): + - `--token` / `-t` : Cloudflare API token (fallback: `CLOUDFLARE_API_TOKEN` env) - `--zone` / `-z` : Cloudflare zone name (fallback: `DDNS_ZONE_NAME` env) - `--name` / `-n` : DNS record name to update (fallback: `DDNS_DNS_NAME` env) The script supports both `--option value` and `--option=value` formats, making it resilient to different shell environments, including restrictive cron job runners. -Examples --------- +## Examples + +--------- Using CLI flags (preferred): @@ -81,8 +91,9 @@ Backwards compatibility (positional token): DDNS_ZONE_NAME=example.com DDNS_DNS_NAME=host.example.com ./run_ddns.sh xxxxx ``` -Tests ------ +## Tests + +--------- There is a small test suite that validates argument/env validation for `ddns.py` without making network calls. Run it with: @@ -90,15 +101,18 @@ There is a small test suite that validates argument/env validation for python3 -m unittest discover -v ``` -Deployment notes ----------------- +## Deployment notes + +--------- + - Prefer injecting `CLOUDFLARE_API_TOKEN` from your host's environment/secret store rather than embedding it in crontab or files. - Use the included `ddns.timer` / `ddns.service` example or `cron.example` to schedule periodic runs (every 15 minutes recommended). -Token file option ------------------ +## Token file option + +--------- If you store the token on disk (less recommended), create `~/.cloudflare_token` with strict permissions and ensure only the first line contains the token: @@ -109,17 +123,20 @@ chmod 600 ~/.cloudflare_token `run_ddns.sh` will read and trim the first line if no env var/flag is provided. -Locking and non-overlap ------------------------- +## Locking and non-overlap + +--------- The runner uses `/tmp/ddns-runner.lock` (flock) to avoid overlapping runs. -License & contribution ----------------------- +## License & contribution + +--------- This utility is MIT-like in spirit — adapt as needed, but never commit secrets to source. Use CI secrets or a secret manager for deployments. -Pre-commit (optional but recommended) ------------------------------------- +## Pre-commit (optional but recommended) + +--------- This repo includes a `.pre-commit-config.yaml` with recommended checks: - ruff (auto-fix) and pylint for Python linting diff --git a/ddns.py b/ddns.py index 0ec3869..cba9f48 100644 --- a/ddns.py +++ b/ddns.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-arguments """Simple DDNS updater for Cloudflare using the REST API. This script updates all A records for a given name in a Cloudflare zone to @@ -12,7 +13,7 @@ import argparse import logging from logging.handlers import RotatingFileHandler -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Union # Third-party imports import requests @@ -44,7 +45,10 @@ LOG_LEVEL = os.getenv('DDNS_LOG_LEVEL', 'INFO').upper() logger = logging.getLogger('ddns') +if logger.hasHandlers(): + logger.handlers.clear() # Clear existing handlers to avoid duplicates logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO)) + formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s') ch = logging.StreamHandler() @@ -56,15 +60,70 @@ logger.addHandler(fh) +def generic_http_request( + method: str, + url: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + timeout: int = 15, + expect_json: bool = True +) -> Union[Dict[str, Any], str]: + """ + Generic HTTP request function supporting JSON and raw text responses. + + Args: + method: HTTP method as a string, e.g., 'GET', 'POST' + url: Full URL to send the request to + params: Query parameters for GET requests + data: JSON body for POST/PUT requests + headers: HTTP headers to include + timeout: Request timeout seconds + expect_json: If True, parse response as JSON; if False, return raw text + + Returns: + Parsed JSON dict if expect_json is True, else response text string + + Raises: + requests.RequestException on network or HTTP errors + ValueError if expected JSON but response is not valid JSON or Content-Type mismatch + """ + req_headers = headers or {} + if expect_json and 'Accept' not in req_headers: + req_headers['Accept'] = 'application/json' + + response = requests.request( + method=method, + url=url, + params=params, + json=data, + headers=req_headers, + timeout=timeout + ) + response.raise_for_status() + + if expect_json: + content_type = response.headers.get('Content-Type', '') + if 'application/json' not in content_type: + raise ValueError(f"Expected JSON response but got Content-Type: {content_type}") + try: + return response.json() + except ValueError as e: + raise ValueError("Response content is not valid JSON") from e + else: + return response.text.strip() + + def cf_get(path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Perform a GET against the Cloudflare API and return JSON. Raises requests.RequestException on network errors. """ url = f"{API_BASE}{path}" - r = requests.get(url, headers=HEADERS, params=params, timeout=15) - r.raise_for_status() - return r.json() + res = generic_http_request('GET', url, params=params, headers=HEADERS, timeout=15) + if not isinstance(res, dict): + raise ValueError("Expected JSON response as a dict but got something else") + return res def cf_patch(path: str, json_data: Dict[str, Any]) -> Dict[str, Any]: @@ -77,9 +136,10 @@ def cf_patch(path: str, json_data: Dict[str, Any]) -> Dict[str, Any]: ) return {"success": True, "result": json_data} url = f"{API_BASE}{path}" - r = requests.patch(url, headers=HEADERS, json=json_data, timeout=15) - r.raise_for_status() - return r.json() + res = generic_http_request('PATCH', url, data=json_data, headers=HEADERS, timeout=15) + if not isinstance(res, dict): + raise ValueError("Expected JSON response as a dict but got something else") + return res def get_zone_id(zone_name: str) -> Optional[str]: @@ -136,7 +196,10 @@ def get_public_ip() -> str: Raises on network failures. """ - return requests.get('https://api.ipify.org', timeout=10).text.strip() + res = generic_http_request('GET', 'https://api.ipify.org', timeout=10, expect_json=False) + if not isinstance(res, str): + raise ValueError("Expected text response for public IP but got something else") + return res def main() -> int: @@ -171,9 +234,9 @@ def main() -> int: # --- Main Logic --- records = get_dns_records(zone_id, dns_name, 'A') - if not records: + if not records or len(records) == 0: logger.info('No A record found for %s in zone %s', dns_name, zone_name) - return 0 + return 1 new_ip = get_public_ip() any_updated = False diff --git a/run_ddns.sh b/run_ddns.sh index 061ce23..35bac7f 100755 --- a/run_ddns.sh +++ b/run_ddns.sh @@ -211,7 +211,14 @@ set -e echo "$DDNS_OUTPUT" echo "DDNS script finished with exit code $DDNS_EXIT_CODE." - +# --- Exit Code Handling --- +# 0 -> Success, IP updated +# 1 -> No A records found on Cloudflare +# 2 -> CLOUDFLARE_API_TOKEN missing +# 3 -> Generic Netwrork Errors +# 4 -> Zone not found +# 6 -> Zone and/or DNS name missing +# 7 -> IP address is already up-to-date # Handle the special exit code for "up-to-date" if [ "$DDNS_EXIT_CODE" -eq 7 ]; then echo "IP address is already up-to-date. No notification will be sent." diff --git a/tests/test_main_logic.py b/tests/test_main_logic.py index cbebc19..2826f34 100644 --- a/tests/test_main_logic.py +++ b/tests/test_main_logic.py @@ -1,142 +1,200 @@ -""" -Tests for the main logic of the ddns.py script, using mocks to isolate -from network operations. -""" -# Standard library imports +"""Unit tests covering the main logic of the ddns.py script.""" + import os import sys import unittest -from unittest.mock import patch - -# Add the parent directory to the path to allow importing 'ddns' -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -import ddns # pylint: disable=import-error,wrong-import-position +from unittest.mock import patch, Mock +import importlib class TestMainLogic(unittest.TestCase): - """Test the main function's logic flows and exit codes.""" + """Test cases for the ddns.py main function.""" def setUp(self): - """Set common environment variables for all tests in this class.""" - os.environ['CLOUDFLARE_API_TOKEN'] = 'fake-token' - os.environ['DDNS_ZONE_NAME'] = 'example.com' - os.environ['DDNS_DNS_NAME'] = 'host.example.com' - # Ensure we are not in dry-run mode for these tests to check update calls - os.environ['DDNS_DRY_RUN'] = '0' + """Set environment and reload ddns module before each test.""" + self.env_patcher = patch.dict( + os.environ, + { + 'CLOUDFLARE_API_TOKEN': 'fake-token', + 'DDNS_ZONE_NAME': 'example.com', + 'DDNS_DNS_NAME': 'host.example.com', + 'DDNS_DRY_RUN': '0', + }, + clear=True, + ) + self.env_patcher.start() + self.ddns = importlib.reload(importlib.import_module("ddns")) def tearDown(self): - """Clean up environment variables after tests.""" - del os.environ['CLOUDFLARE_API_TOKEN'] - del os.environ['DDNS_ZONE_NAME'] - del os.environ['DDNS_DNS_NAME'] - del os.environ['DDNS_DRY_RUN'] - - @patch('ddns.update_a_record') - @patch('ddns.get_public_ip', return_value='192.0.2.100') - @patch('ddns.get_dns_records') - @patch('ddns.get_zone_id', return_value='fake-zone-id') - def test_main_record_needs_update( - self, mock_get_zone_id, mock_get_dns_records, mock_get_public_ip, mock_update_a_record - ): - """Verify main() updates a record and returns exit code 0.""" - # Arrange: Mock get_dns_records to return a record with a different IP - mock_get_dns_records.return_value = [ - {'id': 'rec-123', 'name': 'host.example.com', 'content': '192.0.2.1'} - ] - - # Act: Run the main function, mocking sys.argv to prevent it from - # parsing the unittest runner's arguments. - with patch.object(sys, 'argv', ['ddns.py']): + """Remove environment patch after each test.""" + self.env_patcher.stop() + + def test_main_record_needs_update(self): + """Test main() updates record when IP differs.""" + ddns = self.ddns + with patch.object(ddns, 'update_a_record') as mock_update, \ + patch.object(ddns, 'get_public_ip', return_value='192.0.2.100'), \ + patch.object( + ddns, + 'get_dns_records', + return_value=[{'id': 'rec-123', 'content': '192.0.2.1'}] + ), \ + patch.object(ddns, 'get_zone_id', return_value='fake-zone-id'), \ + patch.object(sys, 'argv', ['ddns.py']): exit_code = ddns.main() - - # Assert - self.assertEqual(exit_code, 0) - mock_get_zone_id.assert_called_once_with('example.com') # type: ignore # pylint: disable=line-too-long - mock_get_dns_records.assert_called_once_with( - 'fake-zone-id', 'host.example.com', 'A' - ) # type: ignore - mock_get_public_ip.assert_called_once() # type: ignore - # Verify that the update function was called with the correct parameters - mock_update_a_record.assert_called_once_with( - 'fake-zone-id', 'rec-123', '192.0.2.100' - ) # type: ignore - - @patch('ddns.update_a_record') - @patch('ddns.get_public_ip', return_value='192.0.2.1') - @patch('ddns.get_dns_records') - @patch('ddns.get_zone_id', return_value='fake-zone-id') - def test_main_record_already_up_to_date( - self, mock_get_zone_id, mock_get_dns_records, mock_get_public_ip, mock_update_a_record - ): - """Verify main() does nothing and returns exit code 7 if IP is current.""" - # Arrange: Mock get_dns_records to return a record with the same IP - mock_get_dns_records.return_value = [ - {'id': 'rec-123', 'name': 'host.example.com', 'content': '192.0.2.1'} - ] - - # Act: Run the main function, mocking sys.argv. - with patch.object(sys, 'argv', ['ddns.py']): + self.assertEqual(exit_code, 0) + mock_update.assert_called_once_with('fake-zone-id', 'rec-123', '192.0.2.100') + + def test_main_record_already_up_to_date(self): + """Test main() returns up-to-date exit code when IP matches.""" + ddns = self.ddns + with patch.object(ddns, 'update_a_record') as mock_update, \ + patch.object(ddns, 'get_public_ip', return_value='192.0.2.1'), \ + patch.object( + ddns, + 'get_dns_records', + return_value=[{'id': 'rec-123', 'content': '192.0.2.1'}] + ), \ + patch.object(ddns, 'get_zone_id', return_value='fake-zone-id'), \ + patch.object(sys, 'argv', ['ddns.py']): exit_code = ddns.main() - - # Assert - self.assertEqual(exit_code, 7) - mock_get_zone_id.assert_called_once_with('example.com') # type: ignore - mock_get_dns_records.assert_called_once_with( # type: ignore - 'fake-zone-id', - 'host.example.com', - 'A' - ) - mock_get_public_ip.assert_called_once() # type: ignore - # Verify that the update function was NOT called - mock_update_a_record.assert_not_called() # type: ignore - - @patch( - 'ddns.get_public_ip', - side_effect=ddns.requests.exceptions.RequestException( - "Connection failed" - ) - ) - @patch('ddns.get_dns_records') - @patch('ddns.get_zone_id', return_value='fake-zone-id') - def test_main_network_error_returns_3( - self, - _mock_get_zone_id, - _mock_get_dns_records, - mock_get_public_ip - ): - """Verify main() returns exit code 3 on a network error.""" - # Arrange: Mocks are set up. get_public_ip will raise an exception. - - # Act: Run the main function. - with patch.object(sys, 'argv', ['ddns.py']): + self.assertEqual(exit_code, 7) + mock_update.assert_not_called() + + def test_main_network_error_returns_3(self): + """Test main() returns network error exit code on exception.""" + ddns = self.ddns + with patch.object( + ddns, + 'get_public_ip', + side_effect=ddns.requests.exceptions.RequestException("fail") + ), \ + patch.object(ddns, 'get_dns_records', return_value=[{'id': 'rec-123'}]), \ + patch.object(ddns, 'get_zone_id', return_value='fake-zone-id'), \ + patch.object(sys, 'argv', ['ddns.py']): exit_code = ddns.main() - - # Assert - self.assertEqual(exit_code, 3) - # Ensure we tried to get the IP, which is where the error occurs - mock_get_public_ip.assert_called_once() # type: ignore - - @patch('ddns.requests.patch') # Patch the underlying requests.patch call - @patch('ddns.get_public_ip', return_value='192.0.2.100') - @patch('ddns.get_dns_records') - @patch('ddns.get_zone_id', return_value='fake-zone-id') - def test_main_dry_run_does_not_update( - self, _mock_get_zone_id, mock_get_dns_records, _mock_get_public_ip, mock_requests_patch - ): - """Verify main() does not attempt a real update when DRY_RUN is active.""" - # Arrange: Set DDNS_DRY_RUN to '1' to enable dry-run mode. - # We need to reload the module for the module-level DRY_RUN constant to update. - os.environ['DDNS_DRY_RUN'] = '1' - mock_get_dns_records.return_value = [ - {'id': 'rec-123', 'name': 'host.example.com', 'content': '192.0.2.1'} - ] - - # Act: Run the main function. - with patch.object(sys, 'argv', ['ddns.py']): + self.assertEqual(exit_code, 3) + + def test_main_dry_run_does_not_update(self): + """Test main() does not call patch request during dry run.""" + ddns = self.ddns + with patch.dict(os.environ, {'DDNS_DRY_RUN': '1'}, clear=False): + importlib.reload(ddns) + with patch.object(ddns.requests, 'patch') as mock_patch, \ + patch.object(ddns, 'get_public_ip', return_value='192.0.2.100'), \ + patch.object( + ddns, + 'get_dns_records', + return_value=[{'id': 'rec-123', 'content': '192.0.2.1'}] + ), \ + patch.object(ddns, 'get_zone_id', return_value='fake-zone-id'), \ + patch.object(sys, 'argv', ['ddns.py']): + exit_code = ddns.main() + self.assertEqual(exit_code, 0) + mock_patch.assert_not_called() + + def test_main_zone_not_found_returns_4(self): + """Test main() returns exit code for missing zone.""" + ddns = self.ddns + with patch.object(ddns, 'get_zone_id', return_value=None), \ + patch.object(sys, 'argv', ['ddns.py']): + exit_code = ddns.main() + self.assertEqual(exit_code, 4) + + def test_main_no_dns_records_returns_1(self): + """Test main() returns exit code when no DNS A records found.""" + ddns = self.ddns + with patch.object(ddns, 'get_zone_id', return_value='fake-zone-id'), \ + patch.object(ddns, 'get_dns_records', return_value=[]), \ + patch.object(sys, 'argv', ['ddns.py']): exit_code = ddns.main() + self.assertEqual(exit_code, 1) - # Assert - self.assertEqual(exit_code, 0, "Exit code should be 0 for a successful dry-run update.") - # Verify that the underlying `requests.patch` function was NEVER called. - mock_requests_patch.assert_not_called() + def test_main_missing_env_vars_returns_2_and_6(self): + """Test various missing environment variable scenarios.""" + ddns_mod = importlib.import_module("ddns") + + with patch.dict(os.environ, {}, clear=True): + importlib.reload(ddns_mod) + with patch.object(sys, 'argv', ['ddns.py']): + self.assertEqual(ddns_mod.main(), 2) + + with patch.dict(os.environ, {'CLOUDFLARE_API_TOKEN': 'token'}, clear=True): + importlib.reload(ddns_mod) + with patch.object(sys, 'argv', ['ddns.py']): + self.assertEqual(ddns_mod.main(), 6) + + with patch.dict(os.environ, {'CLOUDFLARE_API_TOKEN': 'token', + 'DDNS_ZONE_NAME': 'zone'}, clear=True): + importlib.reload(ddns_mod) + with patch.object(sys, 'argv', ['ddns.py']): + self.assertEqual(ddns_mod.main(), 6) + + +class TestGenericHttpRequest(unittest.TestCase): + """Test generic_http_request handling JSON and text methods.""" + + def setUp(self): + self.env_patcher = patch.dict(os.environ, { + 'CLOUDFLARE_API_TOKEN': 'fake-token', + }, clear=True) + self.env_patcher.start() + self.ddns = importlib.reload(importlib.import_module("ddns")) + + def tearDown(self): + self.env_patcher.stop() + + def test_json_response_success(self): + """Test JSON response is correctly parsed.""" + ddns = self.ddns + with patch.object(ddns.requests, 'request') as mock_request: + mock_resp = Mock() + mock_resp.raise_for_status = Mock() + mock_resp.headers = {'Content-Type': 'application/json'} + mock_resp.json.return_value = {'key': 'value'} + mock_request.return_value = mock_resp + + result = ddns.generic_http_request('GET', 'http://fakeurl', expect_json=True) + self.assertEqual(result, {'key': 'value'}) + + def test_json_response_invalid_json_raises(self): + """Test invalid JSON response raises ValueError.""" + ddns = self.ddns + with patch.object(ddns.requests, 'request') as mock_request: + mock_resp = Mock() + mock_resp.raise_for_status = Mock() + mock_resp.headers = {'Content-Type': 'application/json'} + mock_resp.json.side_effect = ValueError("Invalid JSON") + mock_request.return_value = mock_resp + + with self.assertRaises(ValueError): + ddns.generic_http_request('GET', 'http://fakeurl', expect_json=True) + + def test_json_response_wrong_content_type_raises(self): + """Test wrong content-type raises ValueError on JSON expectation.""" + ddns = self.ddns + with patch.object(ddns.requests, 'request') as mock_request: + mock_resp = Mock() + mock_resp.raise_for_status = Mock() + mock_resp.headers = {'Content-Type': 'text/html'} + mock_request.return_value = mock_resp + + with self.assertRaises(ValueError): + ddns.generic_http_request('GET', 'http://fakeurl', expect_json=True) + + def test_text_response_success(self): + """Test plain text response is returned correctly. """ + ddns = self.ddns + with patch.object(ddns.requests, 'request') as mock_request: + mock_resp = Mock() + mock_resp.raise_for_status = Mock() + mock_resp.headers = {'Content-Type': 'text/plain'} + mock_resp.text = 'plain response text' + mock_request.return_value = mock_resp + + result = ddns.generic_http_request('GET', 'http://fakeurl', expect_json=False) + self.assertEqual(result, 'plain response text') + + +if __name__ == '__main__': + unittest.main()