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()