From 492e4fbf91f8f027476c4d8369f980449185dbde Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Jul 2025 18:00:43 +0000 Subject: [PATCH] Add No-IP (noip.com) Dynamic DNS Provider Support (#505) * Initial plan * Implement No-IP (noip.com) provider with comprehensive tests and documentation Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> * Fix lint issues and address code review feedback Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> * Update README files to include No-IP provider documentation links Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> * Refactor No-IP documentation for clarity and consistency; update JSON schema to include additional DNS providers --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> Co-authored-by: New Future --- README.md | 6 +- ddns/provider/__init__.py | 5 + ddns/provider/noip.py | 117 +++++++++++ doc/providers/README.md | 4 + doc/providers/noip.en.md | 75 +++++++ doc/providers/noip.md | 75 +++++++ schema/v4.0.json | 9 +- tests/test_provider_noip.py | 400 ++++++++++++++++++++++++++++++++++++ 8 files changed, 685 insertions(+), 6 deletions(-) create mode 100644 ddns/provider/noip.py create mode 100644 doc/providers/noip.en.md create mode 100644 doc/providers/noip.md create mode 100644 tests/test_provider_noip.py diff --git a/README.md b/README.md index 86a36c5..09a6452 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ - [HE.net](https://dns.he.net/) (@NN708) (不支持自动创建记录) - [华为云](https://huaweicloud.com/) (@cybmp3) ⚡ - [腾讯云](https://cloud.tencent.com/) ([配置指南](doc/providers/tencentcloud.md)) ⚡ + - [No-IP](https://www.noip.com/) ([配置指南](doc/providers/noip.md)) - 自定义回调 API ([配置指南](doc/providers/callback.md)) > ⚡ 标记的服务商使用高级 HMAC-SHA256 签名认证,提供企业级安全保障 @@ -122,6 +123,7 @@ - **HE.net**: [DDNS 文档](https://dns.he.net/docs.html)(仅需将设置的密码填入 `token` 字段,`id` 字段可留空) - **华为云 DNS**: [APIKEY 申请](https://console.huaweicloud.com/iam/)(点左边访问密钥,然后点新增访问密钥) - **腾讯云 DNS**: [详细配置文档](doc/providers/tencentcloud.md) + - **No-IP**: [用户名和密码](https://www.noip.com/)(使用 No-IP 账户的用户名和密码) | [详细配置文档](doc/providers/noip.md) - **自定义回调**: 参数填写方式请查看下方的自定义回调配置说明 2. 修改配置文件,`ipv4` 和 `ipv6` 字段,为待更新的域名,详细参照配置说明 @@ -172,7 +174,7 @@ python -m ddns -c /path/to/config.json | :----: | :----------------: | :------: | :---------: | :----------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | id | string | √ | 无 | api 访问 ID | Cloudflare 为邮箱(使用 Token 时留空)
HE.net 可留空
华为云为 Access Key ID (AK) | | token | string | √ | 无 | api 授权 token | 部分平台叫 secret key,**反馈粘贴时删除** | -| dns | string | No | `"dnspod"` | dns 服务商 | 阿里 DNS 为 `alidns`,Cloudflare 为 `cloudflare`,dns.com 为 `dnscom`,DNSPOD 国内为 `dnspod`,DNSPOD 国际为 `dnspod_com`,HE.net 为 `he`,华为云为 `huaweidns`,腾讯云为 `tencentcloud`,自定义回调为 `callback`。部分服务商有[详细配置文档](doc/providers/) | +| dns | string | No | `"dnspod"` | dns 服务商 | 阿里 DNS 为 `alidns`,Cloudflare 为 `cloudflare`,dns.com 为 `dnscom`,DNSPOD 国内为 `dnspod`,DNSPOD 国际为 `dnspod_com`,HE.net 为 `he`,华为云为 `huaweidns`,腾讯云为 `tencentcloud`,No-IP 为 `noip`,自定义回调为 `callback`。部分服务商有[详细配置文档](doc/providers/) | | ipv4 | array | No | `[]` | ipv4 域名列表 | 为 `[]` 时,不会获取和更新 IPv4 地址 | | ipv6 | array | No | `[]` | ipv6 域名列表 | 为 `[]` 时,不会获取和更新 IPv6 地址 | | index4 | string\|int\|array | No | `"default"` | ipv4 获取方式 | 可设置 `网卡`、`内网`、`公网`、`正则` 等方式 | @@ -221,7 +223,7 @@ python -m ddns -c /path/to/config.json "$schema": "https://ddns.newfuture.cc/schema/v4.0.json", "id": "12345", "token": "mytokenkey", - "dns": "dnspod 或 dnspod_com 或 alidns 或 dnscom 或 cloudflare 或 he 或 huaweidns 或 tencentcloud 或 callback", + "dns": "dnspod 或 dnspod_com 或 alidns 或 dnscom 或 cloudflare 或 he 或 huaweidns 或 tencentcloud 或 noip 或 callback", "ipv4": ["ddns.newfuture.cc", "ipv4.ddns.newfuture.cc"], "ipv6": ["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"], "index4": 0, diff --git a/ddns/provider/__init__.py b/ddns/provider/__init__.py index 4552e96..44a1396 100644 --- a/ddns/provider/__init__.py +++ b/ddns/provider/__init__.py @@ -8,6 +8,7 @@ from .dnspod import DnspodProvider from .dnspod_com import DnspodComProvider from .he import HeProvider from .huaweidns import HuaweiDNSProvider +from .noip import NoipProvider from .tencentcloud import TencentCloudProvider from .debug import DebugProvider @@ -48,6 +49,10 @@ def get_provider_class(provider_name): "huaweidns": HuaweiDNSProvider, "huawei": HuaweiDNSProvider, # 兼容huawei "huaweicloud": HuaweiDNSProvider, + # no-ip + "noip": NoipProvider, + "no-ip": NoipProvider, # 兼容no-ip + "noip_com": NoipProvider, # 兼容noip.com # callback "callback": CallbackProvider, "webhook": CallbackProvider, # 兼容 diff --git a/ddns/provider/noip.py b/ddns/provider/noip.py new file mode 100644 index 0000000..7ce21cc --- /dev/null +++ b/ddns/provider/noip.py @@ -0,0 +1,117 @@ +# coding=utf-8 +""" +No-IP (noip.com) Dynamic DNS API +@author: GitHub Copilot +""" + +import base64 +from ._base import SimpleProvider, TYPE_FORM + + +class NoipProvider(SimpleProvider): + """ + No-IP (www.noip.com) Dynamic DNS Provider + + No-IP is a popular dynamic DNS service that provides simple HTTP-based + API for updating DNS records. This provider supports the standard + No-IP update protocol. + """ + + API = "https://dynupdate.no-ip.com" + content_type = TYPE_FORM + accept = None # No-IP returns plain text response + decode_response = False # Response is plain text, not JSON + + def _validate(self): + """ + Validate authentication credentials for No-IP + """ + if not self.auth_id: + raise ValueError("No-IP requires username as 'id'") + if not self.auth_token: + raise ValueError("No-IP requires password as 'token'") + + def set_record(self, domain, value, record_type="A", ttl=None, + line=None, **extra): + """ + Update DNS record using No-IP Dynamic Update API + + No-IP API Reference: + - URL: https://dynupdate.no-ip.com/nic/update + - Method: GET or POST + - Authentication: HTTP Basic Auth (username:password) + - Parameters: + - hostname: The hostname to update + - myip: The IP address to set (optional, uses client IP + if not provided) + + Response codes: + - good: Update successful + - nochg: IP address is current, no update performed + - nohost: Hostname supplied does not exist + - badauth: Invalid username/password combination + - badagent: Client disabled + - !donator: An update request was sent including a feature that + is not available + - abuse: Username is blocked due to abuse + """ + self.logger.info("%s => %s(%s)", domain, value, record_type) + + # Prepare request parameters + params = { + "hostname": domain, + "myip": value + } + + # Prepare HTTP Basic Authentication headers + auth_string = "{0}:{1}".format(self.auth_id, self.auth_token) + if hasattr(auth_string, 'encode'): # Python 3 + auth_bytes = auth_string.encode('utf-8') + else: # Python 2 + auth_bytes = auth_string + + auth_b64 = base64.b64encode(auth_bytes).decode('ascii') + headers = { + "Authorization": "Basic {0}".format(auth_b64), + "User-Agent": "DDNS/{0} (ddns@newfuture.cc)".format( + self.version) + } + + try: + # Use GET request as it's the most common method for DDNS + response = self._http("GET", "/nic/update", queries=params, + headers=headers) + + if response is not None: + response_str = str(response).strip() + self.logger.info("No-IP API response: %s", response_str) + + # Check for successful responses + if (response_str.startswith("good") or + response_str.startswith("nochg")): + return True + elif response_str.startswith("nohost"): + self.logger.error( + "Hostname %s does not exist under No-IP account", + domain) + elif response_str.startswith("badauth"): + self.logger.error( + "Invalid No-IP username/password combination") + elif response_str.startswith("badagent"): + self.logger.error("No-IP client disabled") + elif response_str.startswith("!donator"): + self.logger.error( + "Feature not available for No-IP free account") + elif response_str.startswith("abuse"): + self.logger.error("No-IP account blocked due to abuse") + else: + self.logger.error("Unexpected No-IP API response: %s", + response_str) + else: + self.logger.error("Empty response from No-IP API") + + except Exception as e: + self.logger.error("Error updating No-IP record for %s: %s", + domain, e) + + return False diff --git a/doc/providers/README.md b/doc/providers/README.md index da8b93f..5169d72 100644 --- a/doc/providers/README.md +++ b/doc/providers/README.md @@ -11,6 +11,7 @@ | `dnspod` | [DNSPod 中国版](https://www.dnspod.cn/) | [dnspod.md](dnspod.md) | [dnspod.en.md](dnspod.en.md) | 国内最大DNS服务商 | | `alidns` | [阿里云 DNS](https://dns.console.aliyun.com/) | [alidns.md](alidns.md) | [alidns.en.md](alidns.en.md) | 阿里云生态集成 | | `tencentcloud` | [腾讯云 DNSPod](https://cloud.tencent.com/product/cns) | [tencentcloud.md](tencentcloud.md) | [tencentcloud.en.md](tencentcloud.en.md) | 腾讯云DNSPod服务 | +| `noip` | [No-IP](https://www.noip.com/) | [noip.md](noip.md) | [noip.en.md](noip.en.md) | 流行的动态DNS服务 | | `callback` | 自定义API (Webhook) | [callback.md](callback.md) | [callback.en.md](callback.en.md) | 自定义HTTP API | ### 其他支持的Provider @@ -64,6 +65,9 @@ ddns --dns cloudflare --id user@example.com --token your_api_key --ipv4 example. # Cloudflare (使用Token) ddns --dns cloudflare --token your_api_token --ipv4 example.com + +# No-IP +ddns --dns noip --id your_username --token your_password --ipv4 example.com ``` ### JSON配置文件 diff --git a/doc/providers/noip.en.md b/doc/providers/noip.en.md new file mode 100644 index 0000000..fd667d1 --- /dev/null +++ b/doc/providers/noip.en.md @@ -0,0 +1,75 @@ +# No-IP Provider Configuration Guide + +No-IP is a popular dynamic DNS service that supports the standard No-IP Dynamic Update Protocol. + +## Configuration Parameters + +| Parameter | Description | Required | Example | +|-----------|-------------|----------|---------| +| `dns` | Provider name | ✅ | `"noip"` | +| `id` | No-IP username or DDNS ID | ✅ | `"your_username"` | +| `token` | No-IP password or DDNS KEY | ✅ | `"your_password"` | + +## Configuration Examples + +### Basic Configuration + +```json +{ + "dns": "noip", + "id": "your_username", + "token": "your_password", + "ipv4": ["home.example.com"] +} +``` + +### Multiple Domains + +```json +{ + "dns": "noip", + "id": "myusername", + "token": "mypassword", + "ipv4": [ + "home.example.com", + "office.example.com" + ], + "ipv6": ["ipv6.example.com"] +} +``` + +## Authentication Methods + +### Username and Password + +Uses No-IP account username and password for authentication. + +### DDNS KEY Authentication (Recommended) + +Uses DDNS ID and DDNS KEY for authentication, which is more secure. + +How to obtain: Login to [No-IP website](https://www.noip.com/) → Create Dynamic DNS hostname → Generate DDNS KEY + +## Response Codes + +| Response | Meaning | Status | +|----------|---------|--------| +| `good ` | Update successful | ✅ | +| `nochg ` | IP unchanged | ✅ | +| `nohost` | Hostname not found | ❌ | +| `badauth` | Authentication failed | ❌ | +| `badagent` | Client disabled | ❌ | +| `!donator` | Paid account required | ❌ | +| `abuse` | Account banned | ❌ | + +## Troubleshooting + +- **Authentication failed (badauth)**: Check username and password +- **Hostname not found (nohost)**: Check domain spelling +- **Paid feature required (!donator)**: Upgrade account +- **Account banned (abuse)**: Contact support + +## Related Links + +- [No-IP Website](https://www.noip.com/) +- [API Documentation](https://www.noip.com/integrate/request) diff --git a/doc/providers/noip.md b/doc/providers/noip.md new file mode 100644 index 0000000..bde149e --- /dev/null +++ b/doc/providers/noip.md @@ -0,0 +1,75 @@ +# No-IP 配置指南 + +No-IP 是流行的动态 DNS 服务,支持标准的 No-IP 动态更新协议。 + +## 配置参数 + +| 参数 | 说明 | 必需 | 示例 | +|------|------|------|------| +| `dns` | 服务商名称 | ✅ | `"noip"` | +| `id` | No-IP 用户名或 DDNS ID | ✅ | `"your_username"` | +| `token` | No-IP 密码或 DDNS KEY | ✅ | `"your_password"` | + +## 配置示例 + +### 基本配置 + +```json +{ + "dns": "noip", + "id": "your_username", + "token": "your_password", + "ipv4": ["home.example.com"] +} +``` + +### 多个域名 + +```json +{ + "dns": "noip", + "id": "myusername", + "token": "mypassword", + "ipv4": [ + "home.example.com", + "office.example.com" + ], + "ipv6": ["ipv6.example.com"] +} +``` + +## 认证方式 + +### 用户名密码认证 + +使用 No-IP 账户用户名和密码进行认证。 + +### DDNS KEY 认证(推荐) + +使用 DDNS ID 和 DDNS KEY 进行认证,更安全。 + +获取方式:登录 [No-IP 官网](https://www.noip.com/) → 创建动态 DNS 主机名 → 生成 DDNS KEY + +## 响应代码 + +| 响应 | 含义 | 状态 | +|------|------|------| +| `good ` | 更新成功 | ✅ | +| `nochg ` | IP 无变化 | ✅ | +| `nohost` | 主机名不存在 | ❌ | +| `badauth` | 认证失败 | ❌ | +| `badagent` | 客户端被禁用 | ❌ | +| `!donator` | 需要付费账户 | ❌ | +| `abuse` | 账户被封禁 | ❌ | + +## 故障排除 + +- **认证失败 (badauth)**:检查用户名和密码 +- **主机名不存在 (nohost)**:检查域名拼写 +- **需要付费功能 (!donator)**:升级账户 +- **账户被封 (abuse)**:联系客服 + +## 相关链接 + +- [No-IP 官网](https://www.noip.com/) +- [API 文档](https://www.noip.com/integrate/request) diff --git a/schema/v4.0.json b/schema/v4.0.json index a901b9c..0020ed3 100644 --- a/schema/v4.0.json +++ b/schema/v4.0.json @@ -42,15 +42,16 @@ "cloudflare" ], "enum": [ - "dnspod", "alidns", + "callback", "cloudflare", - "dnspod_com", + "debug", "dnscom", + "dnspod_com", + "dnspod", "he", "huaweidns", - "callback", - "debug", + "noip", "tencentcloud" ] }, diff --git a/tests/test_provider_noip.py b/tests/test_provider_noip.py new file mode 100644 index 0000000..cf5be48 --- /dev/null +++ b/tests/test_provider_noip.py @@ -0,0 +1,400 @@ +# coding=utf-8 +""" +Unit tests for NoipProvider (No-IP) + +@author: GitHub Copilot +""" + +from base_test import BaseProviderTestCase, unittest, patch, MagicMock +from ddns.provider.noip import NoipProvider + + +class TestNoipProvider(BaseProviderTestCase): + """Test cases for NoipProvider""" + + def setUp(self): + """Set up test fixtures""" + super(TestNoipProvider, self).setUp() + # No-IP uses both auth_id (username) and auth_token (password) + self.auth_id = "test_username" + self.auth_token = "test_password" + + def test_init_with_basic_config(self): + """Test NoipProvider initialization with basic configuration""" + provider = NoipProvider(self.auth_id, self.auth_token) + self.assertEqual(provider.auth_id, self.auth_id) + self.assertEqual(provider.auth_token, self.auth_token) + self.assertEqual(provider.API, "https://dynupdate.no-ip.com") + self.assertFalse(provider.decode_response) + + def test_class_constants(self): + """Test NoipProvider class constants""" + provider = NoipProvider(self.auth_id, self.auth_token) + self.assertEqual(provider.API, "https://dynupdate.no-ip.com") + self.assertFalse(provider.decode_response) + self.assertIsNone(provider.accept) + # ContentType should be form-encoded + from ddns.provider._base import TYPE_FORM + self.assertEqual(provider.content_type, TYPE_FORM) + + def test_validate_success_with_credentials(self): + """Test _validate method passes with proper credentials""" + provider = NoipProvider(self.auth_id, self.auth_token) + # Should not raise any exception + provider._validate() + + def test_validate_fails_without_auth_id(self): + """Test _validate method fails when auth_id is missing""" + with self.assertRaises(ValueError) as cm: + NoipProvider("", self.auth_token) + self.assertIn("No-IP requires username", str(cm.exception)) + + def test_validate_fails_without_auth_token(self): + """Test _validate method fails when auth_token is missing""" + with self.assertRaises(ValueError) as cm: + NoipProvider(self.auth_id, "") + self.assertIn("No-IP requires password", str(cm.exception)) + + def test_validate_fails_with_both_missing(self): + """Test _validate method fails when both credentials are missing""" + with self.assertRaises(ValueError) as cm: + NoipProvider("", "") + self.assertIn("No-IP requires username", str(cm.exception)) + + @patch.object(NoipProvider, "_http") + def test_set_record_success_good_response(self, mock_http): + """Test set_record method with 'good' response""" + mock_http.return_value = "good 192.168.1.1" + + provider = NoipProvider(self.auth_id, self.auth_token) + + result = provider.set_record("example.com", "192.168.1.1", "A") + + # Verify the result + self.assertTrue(result) + + # Verify _http was called with correct parameters + mock_http.assert_called_once() + args, kwargs = mock_http.call_args + self.assertEqual(args[0], "GET") # method + self.assertEqual(args[1], "/nic/update") # path + + # Check query parameters + queries = kwargs["queries"] + self.assertEqual(queries["hostname"], "example.com") + self.assertEqual(queries["myip"], "192.168.1.1") + + # Check authentication header + headers = kwargs["headers"] + self.assertIn("Authorization", headers) + self.assertTrue(headers["Authorization"].startswith("Basic ")) + + @patch.object(NoipProvider, "_http") + def test_set_record_success_nochg_response(self, mock_http): + """Test set_record method with 'nochg' response""" + mock_http.return_value = "nochg 192.168.1.1" + + provider = NoipProvider(self.auth_id, self.auth_token) + + result = provider.set_record("test.example.com", "192.168.1.1", "A") + + # Verify the result + self.assertTrue(result) + + # Verify _http was called with correct parameters + mock_http.assert_called_once() + args, kwargs = mock_http.call_args + self.assertEqual(args[0], "GET") + self.assertEqual(args[1], "/nic/update") + + # Check query parameters + queries = kwargs["queries"] + self.assertEqual(queries["hostname"], "test.example.com") + self.assertEqual(queries["myip"], "192.168.1.1") + + @patch.object(NoipProvider, "_http") + def test_set_record_ipv6_address(self, mock_http): + """Test set_record method with IPv6 address""" + mock_http.return_value = "good 2001:db8::1" + + provider = NoipProvider(self.auth_id, self.auth_token) + + result = provider.set_record("ipv6.example.com", "2001:db8::1", "AAAA") + + # Verify the result + self.assertTrue(result) + + # Check query parameters + args, kwargs = mock_http.call_args + queries = kwargs["queries"] + self.assertEqual(queries["hostname"], "ipv6.example.com") + self.assertEqual(queries["myip"], "2001:db8::1") + + @patch.object(NoipProvider, "_http") + def test_set_record_with_all_parameters(self, mock_http): + """Test set_record method with all optional parameters""" + mock_http.return_value = "good 10.0.0.1" + + provider = NoipProvider(self.auth_id, self.auth_token) + + result = provider.set_record( + domain="full.example.com", + value="10.0.0.1", + record_type="A", + ttl=300, + line="default", + extra_param="test" + ) + + # Verify the result + self.assertTrue(result) + + # Check that core parameters are still correct + args, kwargs = mock_http.call_args + queries = kwargs["queries"] + self.assertEqual(queries["hostname"], "full.example.com") + self.assertEqual(queries["myip"], "10.0.0.1") + + @patch.object(NoipProvider, "_http") + def test_set_record_nohost_error(self, mock_http): + """Test set_record method with 'nohost' error response""" + mock_http.return_value = "nohost" + + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + result = provider.set_record("example.com", "192.168.1.1") + self.assertFalse(result) + + # Verify error was logged + provider.logger.error.assert_called_once() + args = provider.logger.error.call_args[0] + self.assertIn("does not exist", args[0]) + + @patch.object(NoipProvider, "_http") + def test_set_record_badauth_error(self, mock_http): + """Test set_record method with 'badauth' error response""" + mock_http.return_value = "badauth" + + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + result = provider.set_record("example.com", "192.168.1.1") + self.assertFalse(result) + + # Verify error was logged + provider.logger.error.assert_called_once() + args = provider.logger.error.call_args[0] + self.assertIn("Invalid No-IP username/password", args[0]) + + @patch.object(NoipProvider, "_http") + def test_set_record_badagent_error(self, mock_http): + """Test set_record method with 'badagent' error response""" + mock_http.return_value = "badagent" + + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + result = provider.set_record("example.com", "192.168.1.1") + self.assertFalse(result) + + # Verify error was logged + provider.logger.error.assert_called_once() + args = provider.logger.error.call_args[0] + self.assertIn("client disabled", args[0]) + + @patch.object(NoipProvider, "_http") + def test_set_record_donator_error(self, mock_http): + """Test set_record method with '!donator' error response""" + mock_http.return_value = "!donator" + + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + result = provider.set_record("example.com", "192.168.1.1") + self.assertFalse(result) + + # Verify error was logged + provider.logger.error.assert_called_once() + args = provider.logger.error.call_args[0] + self.assertIn("not available for No-IP free account", args[0]) + + @patch.object(NoipProvider, "_http") + def test_set_record_abuse_error(self, mock_http): + """Test set_record method with 'abuse' error response""" + mock_http.return_value = "abuse" + + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + result = provider.set_record("example.com", "192.168.1.1") + self.assertFalse(result) + + # Verify error was logged + provider.logger.error.assert_called_once() + args = provider.logger.error.call_args[0] + self.assertIn("blocked due to abuse", args[0]) + + @patch.object(NoipProvider, "_http") + def test_set_record_unexpected_response(self, mock_http): + """Test set_record method with unexpected response""" + mock_http.return_value = "unknown_response" + + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + result = provider.set_record("example.com", "192.168.1.1") + self.assertFalse(result) + + # Verify error was logged + provider.logger.error.assert_called_once() + args = provider.logger.error.call_args[0] + self.assertIn("Unexpected No-IP API response", args[0]) + + @patch.object(NoipProvider, "_http") + def test_set_record_empty_response_error(self, mock_http): + """Test set_record method with empty response""" + mock_http.return_value = "" + + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + result = provider.set_record("example.com", "192.168.1.1") + self.assertFalse(result) + + # Verify error was logged - empty string should be treated as + # unexpected response + provider.logger.error.assert_called_once() + args = provider.logger.error.call_args[0] + self.assertIn("Unexpected No-IP API response", args[0]) + + @patch.object(NoipProvider, "_http") + def test_set_record_none_response_error(self, mock_http): + """Test set_record method with None response""" + mock_http.return_value = None + + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + result = provider.set_record("example.com", "192.168.1.1") + self.assertFalse(result) + + # Verify error was logged + provider.logger.error.assert_called_once() + args = provider.logger.error.call_args[0] + self.assertIn("Empty response from No-IP API", args[0]) + + @patch.object(NoipProvider, "_http") + def test_set_record_http_exception(self, mock_http): + """Test set_record method when _http raises an exception""" + mock_http.side_effect = Exception("Network error") + + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + result = provider.set_record("example.com", "192.168.1.1") + self.assertFalse(result) + + # Verify error was logged + provider.logger.error.assert_called_once() + args = provider.logger.error.call_args[0] + self.assertEqual(args[0], "Error updating No-IP record for %s: %s") + self.assertEqual(args[1], "example.com") + self.assertIsInstance(args[2], Exception) + + def test_authentication_header_creation(self): + """Test that authentication header is created correctly""" + provider = NoipProvider("test_user", "test_pass") + + # Test the auth header creation manually + import base64 + auth_string = "test_user:test_pass" + if hasattr(auth_string, 'encode'): # Python 3 + auth_bytes = auth_string.encode('utf-8') + else: # Python 2 + auth_bytes = auth_string + + expected_auth_b64 = base64.b64encode(auth_bytes).decode('ascii') + expected_header = "Basic {0}".format(expected_auth_b64) + + with patch.object(provider, "_http") as mock_http: + mock_http.return_value = "good 1.2.3.4" + provider.set_record("test.com", "1.2.3.4") + + # Check that the Authorization header was set correctly + args, kwargs = mock_http.call_args + headers = kwargs["headers"] + self.assertEqual(headers["Authorization"], expected_header) + + def test_set_record_logger_info_called(self): + """Test that logger.info is called with correct parameters""" + provider = NoipProvider(self.auth_id, self.auth_token) + provider.logger = MagicMock() + + with patch.object(provider, "_http") as mock_http: + mock_http.return_value = "good 192.168.1.1" + provider.set_record("example.com", "192.168.1.1", "A") + + # Verify logger.info was called for initial log + provider.logger.info.assert_any_call( + "%s => %s(%s)", "example.com", "192.168.1.1", "A") + + +class TestNoipProviderIntegration(BaseProviderTestCase): + """Integration tests for NoipProvider""" + + def test_full_workflow_ipv4_success(self): + """Test complete workflow for IPv4 record with success response""" + provider = NoipProvider("test_user", "test_pass") + + with patch.object(provider, "_http") as mock_http: + mock_http.return_value = "good 1.2.3.4" + + result = provider.set_record( + "test.com", "1.2.3.4", "A", 300, "default") + + self.assertTrue(result) + mock_http.assert_called_once() + args, kwargs = mock_http.call_args + self.assertEqual(args[0], "GET") + self.assertEqual(args[1], "/nic/update") + + queries = kwargs["queries"] + self.assertEqual(queries["hostname"], "test.com") + self.assertEqual(queries["myip"], "1.2.3.4") + + headers = kwargs["headers"] + self.assertIn("Authorization", headers) + self.assertIn("User-Agent", headers) + + def test_full_workflow_ipv6_success(self): + """Test complete workflow for IPv6 record with success response""" + provider = NoipProvider("test_user", "test_pass") + + with patch.object(provider, "_http") as mock_http: + mock_http.return_value = "good ::1" + + result = provider.set_record( + "test.com", "::1", "AAAA", 600, "telecom") + + self.assertTrue(result) + mock_http.assert_called_once() + + args, kwargs = mock_http.call_args + queries = kwargs["queries"] + self.assertEqual(queries["hostname"], "test.com") + self.assertEqual(queries["myip"], "::1") + + def test_full_workflow_error_handling(self): + """Test complete workflow with error handling""" + provider = NoipProvider("test_user", "test_pass") + + with patch.object(provider, "_http") as mock_http: + mock_http.return_value = "badauth" + + result = provider.set_record("test.com", "1.2.3.4", "A") + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main()