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 <NewFuture@users.noreply.github.com>
This commit is contained in:
Copilot
2025-07-04 18:00:43 +00:00
committed by GitHub
co-authored by NewFuture New Future
parent 3d155b671e
commit 492e4fbf91
8 changed files with 685 additions and 6 deletions
+4 -2
View File
@@ -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 时留空)<br>HE.net 可留空<br>华为云为 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,
+5
View File
@@ -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, # 兼容
+117
View File
@@ -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
+4
View File
@@ -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配置文件
+75
View File
@@ -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 <ip>` | Update successful | ✅ |
| `nochg <ip>` | 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)
+75
View File
@@ -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 <ip>` | 更新成功 | ✅ |
| `nochg <ip>` | IP 无变化 | ✅ |
| `nohost` | 主机名不存在 | ❌ |
| `badauth` | 认证失败 | ❌ |
| `badagent` | 客户端被禁用 | ❌ |
| `!donator` | 需要付费账户 | ❌ |
| `abuse` | 账户被封禁 | ❌ |
## 故障排除
- **认证失败 (badauth)**:检查用户名和密码
- **主机名不存在 (nohost)**:检查域名拼写
- **需要付费功能 (!donator)**:升级账户
- **账户被封 (abuse)**:联系客服
## 相关链接
- [No-IP 官网](https://www.noip.com/)
- [API 文档](https://www.noip.com/integrate/request)
+5 -4
View File
@@ -42,15 +42,16 @@
"cloudflare"
],
"enum": [
"dnspod",
"alidns",
"callback",
"cloudflare",
"dnspod_com",
"debug",
"dnscom",
"dnspod_com",
"dnspod",
"he",
"huaweidns",
"callback",
"debug",
"noip",
"tencentcloud"
]
},
+400
View File
@@ -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()