diff --git a/ddns/__main__.py b/ddns/__main__.py index eb34315..45cefb0 100755 --- a/ddns/__main__.py +++ b/ddns/__main__.py @@ -75,7 +75,7 @@ def update_ip(dns, cache, index_rule, domains, record_type, config, cancelled=No 更新IP并变更DNS记录 """ _raise_if_cancelled(cancelled) - if not domains: + if not domains or index_rule is False: return None ip_type = "4" if record_type == "A" else "6" @@ -84,7 +84,7 @@ def update_ip(dns, cache, index_rule, domains, record_type, config, cancelled=No logger.error("Fail to get %s address!", ip_type) return False - update_success = False + update_success = True for domain in domains: _raise_if_cancelled(cancelled) @@ -92,21 +92,22 @@ def update_ip(dns, cache, index_rule, domains, record_type, config, cancelled=No cache_key = "{}:{}".format(domain, record_type) if cache and cache.get(cache_key) == address: logger.info("%s[%s] address not changed, using cache: %s", domain, record_type, address) - update_success = True - else: - try: - result = dns.set_record( - domain, address, record_type=record_type, ttl=config.ttl, line=config.line, **config.extra - ) - if result: - logger.warning("set %s[IPv%s]: %s successfully.", domain, ip_type, address) - update_success = True - if isinstance(cache, dict): - cache[cache_key] = address - else: - logger.error("Failed to update %s record for %s", record_type, domain) - except Exception as e: - logger.exception("Failed to update %s record for %s: %s", record_type, domain, e) + continue + + try: + result = dns.set_record( + domain, address, record_type=record_type, ttl=config.ttl, line=config.line, **config.extra + ) + if result: + logger.warning("set %s[IPv%s]: %s successfully.", domain, ip_type, address) + if isinstance(cache, dict): + cache[cache_key] = address + else: + update_success = False + logger.error("Failed to update %s record for %s", record_type, domain) + except Exception as e: + update_success = False + logger.exception("Failed to update %s record for %s: %s", record_type, domain, e) _raise_if_cancelled(cancelled) return update_success @@ -125,10 +126,9 @@ def run(config, cancelled=None): config.id, config.token, endpoint=config.endpoint, logger=logger, proxy=config.proxy, ssl=config.ssl ) cache = Cache.new(config.cache, config.md5(), logger, config.cache_max_age) - return ( - update_ip(dns, cache, config.index4, config.ipv4, "A", config, cancelled=cancelled) is not False - and update_ip(dns, cache, config.index6, config.ipv6, "AAAA", config, cancelled=cancelled) is not False - ) + result4 = update_ip(dns, cache, config.index4, config.ipv4, "A", config, cancelled=cancelled) + result6 = update_ip(dns, cache, config.index6, config.ipv6, "AAAA", config, cancelled=cancelled) + return result4 is not False and result6 is not False def main(): diff --git a/ddns/ip.py b/ddns/ip.py index 60f68b8..99a15b2 100644 --- a/ddns/ip.py +++ b/ddns/ip.py @@ -15,7 +15,12 @@ ssl_verify = "auto" IPV4_REG = r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])" # IPV6正则 # https://community.helpsystems.com/forums/intermapper/miscellaneous-topics/5acc4fcf-fa83-e511-80cf-0050568460e4 -IPV6_REG = r"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))" # noqa: E501 +# Keep full IPv6 literals while allowing compact text labels such as IP:
. +IPV6_REG = ( + r"(?:(?(null) const providerPickerTrigger = ref(null) const providerSearchInput = ref(null) @@ -1053,12 +1055,12 @@ function buildProvider(provider: ProviderState): JsonObject { else if (provider.logLevel) output.log = { level: provider.logLevel } const extra = parseObjectText(provider.extraText) - if (extra && Object.keys(extra).length) output.extra = extra + if (extra) output.extra = extra return output } function buildConfiguration(): JsonObject { - const globalExtra = parseObjectText(globalState.extraText) || {} + const globalExtra = parseObjectText(globalState.extraText) const output: JsonObject = { $schema: SCHEMA_URL, providers: providers.value.map((provider) => buildProvider(provider)), @@ -1094,7 +1096,7 @@ function buildConfiguration(): JsonObject { } if (Object.keys(log).length) output.log = log - if (Object.keys(globalExtra).length) output.extra = globalExtra + if (globalExtra) output.extra = globalExtra return output } @@ -1357,6 +1359,7 @@ function persistDraft() { validationInput: validationInput.value, baselineValidationInput: baselineValidationInput.value, validatorTouched: validatorTouched.value, + validationFromFile: validationFromFile.value, view: currentDraftView(), } window.sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)) @@ -1383,6 +1386,7 @@ function restoreLegacyDraft(value: Record): boolean { if (!(config.providers as JsonValue[]).some((provider) => isPlainObject(provider))) return false if (!loadConfigurationIntoBuilder(config, false, c.value.legacyDraftRestored)) return false validationInput.value = typeof value.validationInput === 'string' ? value.validationInput : '' + validationFromFile.value = false validatorTouched.value = validationInput.value !== baselineValidationInput.value if (validationInput.value.trim()) inspectorTab.value = 'validate' return true @@ -1405,6 +1409,8 @@ function restoreCurrentDraft(value: Record): boolean { typeof value.validatorTouched === 'boolean' ? value.validatorTouched : validationInput.value !== baselineValidationInput.value + // Older drafts did not record an external saved copy; keep them unexported. + validationFromFile.value = draftBoolean(value, 'validationFromFile') restoreDraftView(value.view) showToast(c.value.draftRestored) return true @@ -1445,6 +1451,7 @@ watch( [ editorSnapshot, validationInput, + validationFromFile, selectedUid, activeSection, inspectorTab, @@ -2964,6 +2971,7 @@ function handleProviderPickerKeydown(event: KeyboardEvent) { async function selectInspectorTab(tab: InspectorTab, focusTab = false) { if (tab === 'validate' && !validatorTouched.value) { validationInput.value = generatedJson.value + validationFromFile.value = false } inspectorTab.value = tab if (!focusTab) return @@ -3317,6 +3325,7 @@ function resetBuilder() { providerQuery.value = '' validationInput.value = '' validatorTouched.value = false + validationFromFile.value = false Object.assign(globalState, makeGlobalState()) nextTick(() => { markValidationHandled() @@ -3371,6 +3380,7 @@ async function importFile(event: Event) { try { validationInput.value = await file.text() validatorTouched.value = true + validationFromFile.value = true inspectorTab.value = 'validate' showToast(c.value.imported) } catch { @@ -3401,21 +3411,31 @@ function sslFromValue(value: unknown, fallback: SslMode): { mode: SslMode; path: return { mode: fallback, path: '' } } -function collectExtra(value: Record, known: Set): JsonObject { - const extra: JsonObject = isPlainObject(value.extra) ? { ...(value.extra as JsonObject) } : {} +function flattenExtraFields(value: Record, known: Set): JsonObject { + const fields: JsonObject = {} Object.keys(value).forEach((key) => { - if (known.has(key)) return const nested = value[key] - if (key.startsWith('extra_')) { - extra[key.slice(6)] = nested as JsonValue - } else if (isPlainObject(nested)) { + if (key === 'extra') { + if (isPlainObject(nested)) fields.extra = nested as JsonObject + return + } + if (known.has(key)) return + if (isPlainObject(nested)) { Object.keys(nested).forEach((nestedKey) => { - extra[`${key}_${nestedKey}`] = nested[nestedKey] as JsonValue + fields[`${key}_${nestedKey}`] = nested[nestedKey] as JsonValue }) } else { - extra[key] = nested as JsonValue + fields[key] = nested as JsonValue } }) + return fields +} + +function collectExtra(fields: JsonObject): JsonObject { + const extra: JsonObject = isPlainObject(fields.extra) ? { ...(fields.extra as JsonObject) } : {} + Object.keys(fields).forEach((key) => { + if (key !== 'extra') extra[key.startsWith('extra_') ? key.slice(6) : key] = fields[key] + }) return extra } @@ -3440,7 +3460,10 @@ function logFieldState( } } -function providerStateFromObject(value: Record): ProviderState { +function providerStateFromObject( + value: Record, + inheritedExtraFields: JsonObject = {}, +): ProviderState { const providerName = typeof value.provider === 'string' ? value.provider @@ -3449,7 +3472,13 @@ function providerStateFromObject(value: Record): ProviderState : '' const cache = cacheFromValue(value.cache, 'inherit') const ssl = sslFromValue(value.ssl, 'inherit') - const extra = collectExtra(value, PROVIDER_KNOWN_KEYS) + const extraFields = flattenExtraFields(value, PROVIDER_KNOWN_KEYS) + // Match the runtime's flatten -> inherit/override -> collect order. Canonical + // provider.extra replaces global.extra, so any override must retain its full + // effective JSON fields; a provider without overrides can still inherit. + const extraText = Object.keys(extraFields).length + ? JSON.stringify(collectExtra({ ...inheritedExtraFields, ...extraFields }), null, 2) + : '' return { uid: nextProviderUid++, provider: providerName, @@ -3481,7 +3510,7 @@ function providerStateFromObject(value: Record): ProviderState cachePath: cache.path, cacheMaxAge: typeof value.cache_max_age === 'number' ? String(value.cache_max_age) : '', logLevel: logLevelFromObject(value), - extraText: Object.keys(extra).length ? JSON.stringify(extra, null, 2) : '', + extraText, revealToken: false, } } @@ -3492,10 +3521,13 @@ function loadConfigurationIntoBuilder( message: string, ): boolean { const importedProviders: ProviderState[] = [] - let importedGlobalExtra: JsonObject = {} + let importedGlobalExtraText = '' if (Array.isArray(value.providers)) { - importedGlobalExtra = collectExtra(value, ROOT_KNOWN_KEYS) + const globalExtraFields = flattenExtraFields(value, ROOT_KNOWN_KEYS) + if (Object.keys(globalExtraFields).length) { + importedGlobalExtraText = JSON.stringify(collectExtra(globalExtraFields), null, 2) + } const inherited: Record = {} ;['ipv4', 'ipv6', 'index4', 'index6', 'ttl', 'line'].forEach((key) => { if (key in value) inherited[key] = value[key] @@ -3503,10 +3535,13 @@ function loadConfigurationIntoBuilder( value.providers.forEach((provider) => { if (isPlainObject(provider)) { importedProviders.push( - providerStateFromObject({ - ...inherited, - ...provider, - }), + providerStateFromObject( + { + ...inherited, + ...provider, + }, + globalExtraFields, + ), ) } }) @@ -3515,7 +3550,8 @@ function loadConfigurationIntoBuilder( ;['dns', 'id', 'token', 'endpoint', 'ipv4', 'ipv6', 'index4', 'index6', 'ttl', 'line'].forEach((key) => { if (key in value) providerObject[key] = value[key] }) - providerObject.extra = collectExtra(value, ROOT_KNOWN_KEYS) + const extraFields = flattenExtraFields(value, ROOT_KNOWN_KEYS) + if (Object.keys(extraFields).length) providerObject.extra = collectExtra(extraFields) importedProviders.push(providerStateFromObject(providerObject)) } @@ -3549,9 +3585,7 @@ function loadConfigurationIntoBuilder( logDatefmt: logDatefmt.value, logDatefmtPresent: logDatefmt.present, logDatefmtNull: logDatefmt.isNull, - extraText: Object.keys(importedGlobalExtra).length - ? JSON.stringify(importedGlobalExtra, null, 2) - : '', + extraText: importedGlobalExtraText, }) activeSection.value = 'provider' inspectorTab.value = 'preview' @@ -3561,7 +3595,6 @@ function loadConfigurationIntoBuilder( runtimeAdvancedOpen.value = hasRuntimeAdvancedSettings.value nextTick(() => { if (markClean) { - markValidationHandled() commitBaseline() } else { scheduleDraftPersistence() @@ -3574,7 +3607,10 @@ function loadConfigurationIntoBuilder( function applyToBuilder() { const parsedValue = parsedValidation.value.value if (!validationCanApply.value || !isPlainObject(parsedValue)) return - loadConfigurationIntoBuilder(parsedValue, true, c.value.applied) + if (loadConfigurationIntoBuilder(parsedValue, validationFromFile.value, c.value.applied)) { + markValidationHandled() + validationFromFile.value = false + } } async function useRuntimeCredential(field: 'id' | 'token') { @@ -4939,7 +4975,10 @@ async function useRuntimeCredential(field: 'id' | 'token') { :aria-invalid="validationErrors.length ? 'true' : undefined" aria-describedby="studio-validator-hint studio-validator-status" :placeholder="c.validatorPlaceholder" - @input="validatorTouched = true" + @input=" + validatorTouched = true; + validationFromFile = false + " >
{{ validationLineCount }} {{ c.lineCount }} diff --git a/docs/config/json.md b/docs/config/json.md index 3bb72c8..6331c02 100644 --- a/docs/config/json.md +++ b/docs/config/json.md @@ -136,6 +136,8 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem * **单个域名**:`"ddns.newfuture.cc"` * **多个域名**:`["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"]` +IPv4 和 IPv6 独立处理:一族被禁用或获取地址失败,不会阻止另一族更新。同步会继续尝试其余域名,但只要任一已启用的记录更新失败,单次运行的 CLI 命令就返回非零退出码,Web/MCP 则报告同步失败。启用缓存时,已成功的记录仍会保留缓存。 + ### index4-index6 `index4`和`index6`参数用于指定获取IP地址的方式,可以使用以下值: @@ -152,6 +154,8 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem * `"cmd:xxx"`:执行指定命令并使用其输出作为IP * `"shell:xxx"`:使用系统shell运行命令并使用其输出作为IP +`public` 和 `url:` 规则只从成功的 HTTP 响应中提取地址;错误响应会继续回退到后续 API 或规则,不会使用错误页面里的 IP。IPv6 地址支持包含点分 IPv4 尾部的完整表示,例如 `2001:db8::192.0.2.10`。 + 配置示例: ```jsonc diff --git a/docs/config/studio.md b/docs/config/studio.md index a408541..6c3bea8 100644 --- a/docs/config/studio.md +++ b/docs/config/studio.md @@ -17,3 +17,9 @@ FORM: 已有文档世界中的完整 Operate 页面;采用“持续可见的 --> + +::: details 导入、草稿与扩展字段 +在页面内创建、粘贴或修改的配置,点击“载入编辑器”不等于导出。浏览器允许临时存储时,尚未导出的完整草稿(包括凭据)会保存在当前标签页,刷新后可恢复;复制或下载后才标记为已导出。未经修改的文件导入仍以原文件为已保存基线。旧草稿若没有记录文件来源,会保守地保留为未导出状态。 + +服务商扩展字段留空表示继承全局 JSON `extra`;填写 `{}` 表示显式空覆盖,不会清除环境变量提供的扩展字段。导入会按运行时优先级处理平铺字段、`extra_` 别名和嵌套 `extra`。为保留有效值,必要时会将继承的字段显式展开到服务商的扩展字段中;这些显式值之后会覆盖全局设置。 +::: diff --git a/docs/en/config/json.md b/docs/en/config/json.md index dde9b99..46f3459 100644 --- a/docs/en/config/json.md +++ b/docs/en/config/json.md @@ -134,6 +134,8 @@ Supported formats: * **Single domain**: `"ddns.newfuture.cc"` * **Multiple domains**: `["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"]` +IPv4 and IPv6 are processed independently: disabling one family or failing to discover its address does not prevent the other family from updating. Synchronization continues with the remaining domains, but any failed enabled record makes a one-shot CLI run exit nonzero; Web/MCP report the synchronization failure. When caching is enabled, successfully updated records are still cached. + ### index4-index6 The `index4` and `index6` parameters are used to specify the method for obtaining IP addresses. The following values can be used: @@ -150,6 +152,8 @@ Supported types: * `"cmd:xxx"`: Execute specified command and use its output as IP * `"shell:xxx"`: Use system shell to run command and use its output as IP +The `public` and `url:` rules extract addresses only from successful HTTP responses. An error response falls back to the next API or rule instead of using an IP found in its error page. IPv6 addresses retain complete dotted-IPv4 tails, such as `2001:db8::192.0.2.10`. + Configuration examples: ```jsonc diff --git a/docs/en/config/studio.md b/docs/en/config/studio.md index 3a46047..31cb291 100644 --- a/docs/en/config/studio.md +++ b/docs/en/config/studio.md @@ -17,3 +17,9 @@ FORM: A complete Operate page inside the established documentation world, using --> + +::: details Imports, drafts, and custom fields +For configurations created, pasted, or edited on this page, **Load into builder** does not count as an export. When temporary browser storage is available, complete unexported drafts (including credentials) are saved in the current tab and can be restored after a refresh; copying or downloading marks them as exported. An unchanged file import still uses the original file as its saved baseline. Older drafts without file-origin information are conservatively retained as unexported. + +Leave provider custom fields blank to inherit the global JSON `extra`; enter `{}` for an explicit empty override, which does not clear custom fields supplied by environment variables. Imports resolve flat fields, `extra_` aliases, and nested `extra` in runtime precedence order. To preserve effective values, inherited fields are expanded into explicit provider custom fields when needed; those explicit values subsequently override global settings. +::: diff --git a/docs/en/providers/callback.md b/docs/en/providers/callback.md index f3b84c9..3b6f5e9 100644 --- a/docs/en/providers/callback.md +++ b/docs/en/providers/callback.md @@ -49,6 +49,12 @@ Return to [All Providers](/en/providers/) to see more options | GET | token is empty | Use URL query parameters | | POST | token is not empty | Use JSON request body | +### HTTP Responses and Caching + +Callback accepts only final HTTP statuses from 200 to 299 as success. Empty response bodies, such as HTTP 204 responses, are allowed. +HTTP 404, 429, and all other non-2xx statuses are failures. The current IP is not cached as a successful update, so a later synchronization will retry. +Callback does not interpret custom business errors in an HTTP 200 response body. To report an update failure, the callback endpoint should return an appropriate non-2xx HTTP status. + ### GET Request Example ```jsonc diff --git a/docs/en/providers/cloudflare.md b/docs/en/providers/cloudflare.md index 5ec5a36..cc6b5d6 100644 --- a/docs/en/providers/cloudflare.md +++ b/docs/en/providers/cloudflare.md @@ -172,6 +172,12 @@ ddns -c config.json --debug - **Record creation failed**: Check record format and TTL values, confirm permission settings - **Rate limit exceeded**: API call frequency exceeded, reduce request frequency +### Error Handling and Caching + +A Cloudflare request is considered successful only when its HTTP status is 2xx and the API returns `success: true`. +A final HTTP error such as 429, an API response with `success: false`, or an invalid response makes the domain update fail. The pending IP is not cached as a successful update, so a later synchronization will retry. +A failed zone or record lookup stops processing that domain; it is not treated as a missing record and cannot trigger record creation. A successful query with an empty record list still follows the normal creation flow. + ## Support and Resources - [Cloudflare Developer Documentation](https://developers.cloudflare.com/) diff --git a/docs/package.json b/docs/package.json index 2903d29..fbb853d 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,9 +4,12 @@ "description": "DDNS Documentation Website", "type": "module", "scripts": { + "test:studio": "node --test tests/config-studio.test.mjs", "docs:dev": "vitepress dev .", + "predocs:build": "npm run test:studio", "docs:build": "vitepress build .", "docs:preview": "vitepress preview .", + "prebuild": "npm run test:studio", "build": "vitepress build ." }, "devDependencies": { diff --git a/docs/providers/callback.md b/docs/providers/callback.md index b4d1887..3dae17f 100644 --- a/docs/providers/callback.md +++ b/docs/providers/callback.md @@ -49,6 +49,12 @@ Callback Provider 是一个通用的自定义回调接口,允许您将 DDNS | GET | token 为空 | 使用 URL 查询参数 | | POST | token 非空 | 使用 JSON 请求体 | +### HTTP 响应与缓存 + +Callback 仅将最终 HTTP 状态为 200–299 的响应视为成功,允许响应体为空,例如 HTTP 204。 +HTTP 404、429 及其他非 2xx 状态均视为失败,不会将本次 IP 写入成功缓存,后续同步仍会重试。 +Callback 不解析 HTTP 200 响应体中的自定义业务错误;如需报告更新失败,请让回调接口返回适当的非 2xx HTTP 状态。 + ### GET 请求示例 ```jsonc diff --git a/docs/providers/cloudflare.md b/docs/providers/cloudflare.md index 9f2a862..dcf4c01 100644 --- a/docs/providers/cloudflare.md +++ b/docs/providers/cloudflare.md @@ -170,6 +170,12 @@ ddns -c config.json --debug - **Record creation failed**:检查记录格式和 TTL 值,确认权限设置 - **Rate limit exceeded**:API 调用频率超限,降低请求频率 +### 错误处理与缓存 + +Cloudflare 请求只有在 HTTP 状态为 2xx 且 API 返回 `success: true` 时才视为成功。 +最终收到 HTTP 429 等错误状态,或 API 返回 `success: false`、无效响应时,本次域名更新会失败,不会将待更新的 IP 写入成功缓存,后续同步仍会重试。 +区域或记录查询失败会终止当前域名的处理,不会被当作“记录不存在”而创建新记录;成功查询返回空记录列表时,仍会正常进入创建流程。 + ## 支持与资源 - [Cloudflare 开发者文档](https://developers.cloudflare.com/) diff --git a/docs/tests/config-studio.test.mjs b/docs/tests/config-studio.test.mjs new file mode 100644 index 0000000..1791df5 --- /dev/null +++ b/docs/tests/config-studio.test.mjs @@ -0,0 +1,320 @@ +// npm --prefix docs run test:studio +// Use the existing Vite/Vue toolchain, not Node's newer TypeScript stripping API. +// Exercise setup, template events and disabled gates with real Vue reactivity; +// browser storage, file selection, clipboard and download are in-memory doubles. +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import vm from 'node:vm' +import { afterEach, test } from 'node:test' +import { transformWithOxc } from 'vite' +import * as Vue from 'vue' + +const sfc = await readFile(new URL('../.vitepress/theme/components/ConfigStudio.vue', import.meta.url), 'utf8') +const model = JSON.parse(await readFile(new URL('../../ddns/config/field-model.json', import.meta.url), 'utf8')) +const setup = sfc.match(/')[1].matchAll(/<(?:button|input|textarea|select)\b(?:[^>"']|"[^"]*"|'[^']*')*>/g)] + .map((match) => match[0]) +const attr = (tag, name) => tag.match(new RegExp(`\\s${name}="([\\s\\S]*?)"`))?.[1] +const plain = (value) => JSON.parse(JSON.stringify(value)) +const scopes = [] +afterEach(() => scopes.splice(0).forEach((scope) => scope.stop())) + +async function studio(storage = new Map()) { + const mounted = [], timers = new Map(), listeners = new Map(), clipboard = [], downloads = [], blobs = new Map() + let timerId = 0 + class MemoryURL extends URL { + static createObjectURL(blob) { const key = `blob:${++timerId}`; blobs.set(key, blob); return key } + static revokeObjectURL(key) { blobs.delete(key) } + } + const context = vm.createContext({ + ...Vue, __DDNS_FIELD_MODEL__: model, + onMounted: (fn) => mounted.push(fn), onBeforeUnmount: () => {}, + useData: () => ({ lang: Vue.ref('en') }), useRouter: () => ({}), withBase: (value) => value, + readHistoryPosition: () => null, URL: MemoryURL, Blob, + setTimeout: (fn, delay) => { const id = ++timerId; timers.set(id, { fn, delay }); return id }, + clearTimeout: (id) => timers.delete(id), + window: { + location: new URL('https://docs.example.com/en/config/studio'), history: { state: null }, + matchMedia: () => ({ matches: true }), confirm: () => true, + addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name), + sessionStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }, + }, + document: { + body: { classList: { toggle() {}, remove() {} } }, + getElementById: () => ({ focus() {} }), + createElement: (name) => { + assert.equal(name, 'a') + return { click() { downloads.push(blobs.get(this.href)) } } + }, + }, + navigator: { clipboard: { async writeText(text) { clipboard.push(text) } } }, + }) + const scope = Vue.effectScope() + scopes.push(scope) + scope.run(() => vm.runInContext(`${script} + globalThis.bindings = { + selectedProvider, globalState, providers, activeSection, inspectorTab, + sourceAdvancedOpen, runtimeAdvancedOpen, validationInput, validatorTouched, + hasUnsavedChanges, validationCanApply, validationErrors, canExport, previewErrors, + generatedJson, importFile, applyToBuilder, copyConfiguration, downloadConfiguration, + selectInspectorTab, toggleProviderPicker, chooseProvider, selectSection, + resetBuilder, duplicateProvider, addProvider, + ...(typeof validationFromFile === 'undefined' ? {} : { validationFromFile }) + }; + globalThis.draftKey = DRAFT_STORAGE_KEY; + globalThis.saveDelay = DRAFT_SAVE_DELAY; + `, context)) + context.ui = Vue.proxyRefs(context.bindings) + const evaluate = (expression, locals = {}) => { + context.locals = locals + return vm.runInContext(`with (ui) { with (locals) { ${expression} } }`, context) + } + const settle = async () => { await Vue.nextTick(); await Vue.nextTick() } + for (const fn of mounted) await fn() + await settle() + const api = { + storage, + get: (name) => evaluate(name), + json: () => JSON.parse(evaluate('generatedJson')), + draft: () => storage.has(context.draftKey) ? JSON.parse(storage.get(context.draftKey)) : null, + async click(handler, locals = {}) { + const tag = tags.find((tag) => tag.startsWith(' attr(tag, 'v-model') === name) + assert(tag, `Missing template input: ${name}`) + evaluate(`${name} = $event.target.value; ${attr(tag, '@input') || ''}`, { $event: { target: { value } } }) + await settle() + }, + async import(value) { + assert(tags.some((tag) => attr(tag, '@change') === 'importFile')) + await evaluate('importFile($event)', { + $event: { target: { value: 'config.json', files: [{ text: async () => JSON.stringify(value) }] } }, + }) + await settle() + assert.deepEqual(plain(evaluate('validationErrors')), []) + assert.equal(evaluate('validationCanApply'), true) + }, + async apply() { await api.click('applyToBuilder') }, + async persist() { + await settle() + for (const [id, timer] of timers) { + if (timer.delay !== context.saveDelay) continue + timers.delete(id) + timer.fn() + } + await settle() + }, + async refresh() { + const event = { warned: false, preventDefault() { this.warned = true } } + listeners.get('beforeunload')(event) + scope.stop() + return { warned: event.warned, page: await studio(storage) } + }, + async copy() { await api.click('copyConfiguration'); return JSON.parse(clipboard.at(-1)) }, + async download() { + await api.click('downloadConfiguration') + return JSON.parse(await downloads.at(-1).text()) + }, + } + return api +} + +const base = { + $schema: model.schema.url, ssl: true, cache: false, log: { level: 'INFO' }, + providers: [{ + provider: 'cloudflare', id: null, token: 'offline-placeholder', + ipv4: ['home.example.com'], ipv6: [], index4: ['public', 'default'], index6: false, ttl: 300, + }], +} +async function importApply(value) { + const page = await studio() + await page.import(value) + await page.apply() + assert.equal(page.get('canExport'), true) + return page +} +async function createUnsaved() { + const page = await studio() + await page.click('toggleProviderPicker') + await page.click('chooseProvider(provider.value)', { provider: { value: 'cloudflare' } }) + await page.input('selectedProvider.token', 'offline-page-token') + await page.click('selectSection(section.key)', { section: { key: 'records' } }) + await page.input('selectedProvider.ipv4Text', 'unsaved.example.com') + await page.click('sourceAdvancedOpen = !sourceAdvancedOpen') + await page.input('selectedProvider.ttl', '600') + await page.persist() + return page +} + +test('page-created, unexported JSON edits survive Apply and refresh', async () => { + const page = await createUnsaved() + await page.click("selectInspectorTab('validate')") + const edited = page.json() + edited.providers[0].ttl = 900 + await page.input('validationInput', JSON.stringify(edited)) + await page.apply() + assert.equal(page.get('hasUnsavedChanges'), true) + await page.persist() + assert(page.draft()) + const { page: restored, warned } = await page.refresh() + assert.equal(warned, true) + assert.deepEqual(restored.json(), edited) + assert.equal(restored.get('validatorTouched'), false) + await restored.copy() + assert.equal(restored.get('hasUnsavedChanges'), false) + assert.equal(restored.draft(), null) +}) + +test('ordinary unsaved refresh and successful export keep their existing behavior', async () => { + const page = await createUnsaved() + const original = page.json() + const { page: restored, warned } = await page.refresh() + assert.equal(warned, true) + assert.deepEqual(restored.json(), original) + assert.deepEqual(await restored.copy(), original) + assert.deepEqual(await restored.download(), original) + assert.equal(restored.get('hasUnsavedChanges'), false) + assert.equal(restored.draft(), null) +}) + +test('unchanged saved-file imports stay clean, including a draft restored before Apply', async () => { + const page = await studio() + await page.import(base) + await page.persist() + const { page: restored } = await page.refresh() + await restored.apply() + assert.equal(restored.get('hasUnsavedChanges'), false) + assert.equal(restored.draft(), null) + assert.deepEqual(await restored.download(), base) +}) + +test('editing imported JSON invalidates its saved-file provenance', async () => { + const page = await studio() + await page.import(base) + const edited = plain(base) + edited.providers[0].ttl = 900 + await page.input('validationInput', JSON.stringify(edited)) + await page.apply() + assert.equal(page.get('hasUnsavedChanges'), true) + const { page: restored } = await page.refresh() + assert.equal(restored.json().providers[0].ttl, 900) +}) + +test('JSON regenerated from an edited builder is not treated as the imported file', async () => { + const page = await importApply(base) + await page.click('selectSection(section.key)', { section: { key: 'records' } }) + await page.input('selectedProvider.ipv4Text', 'changed.example.com') + await page.click("selectInspectorTab('validate')") + await page.apply() + assert.equal(page.get('hasUnsavedChanges'), true) + const { page: restored } = await page.refresh() + assert.deepEqual(restored.json().providers[0].ipv4, ['changed.example.com']) +}) + +test('old v2 drafts without import provenance are retained conservatively', async () => { + const page = await studio() + await page.import(base) + await page.persist() + const draft = page.draft() + delete draft.validationFromFile + const storage = new Map(page.storage) + storage.set([...storage.keys()][0], JSON.stringify(draft)) + const restored = await studio(storage) + await restored.apply() + assert.equal(restored.get('hasUnsavedChanges'), true) +}) + +test('v1 drafts retain explicit empty overrides and unapplied validation text', async () => { + const config = { ...base, extra: { proxied: true }, providers: [{ ...base.providers[0], extra: {} }] } + const validationInput = '{ "unfinished":' + const restored = await studio(new Map([[ + 'ddns-config-studio-draft-v1', + JSON.stringify({ version: 1, config, validationInput }), + ]])) + assert.deepEqual(restored.json().providers[0].extra, {}) + assert.equal(restored.get('validationInput'), validationInput) + assert.equal(restored.get('validatorTouched'), true) + assert.equal(restored.get('hasUnsavedChanges'), true) +}) + +const extraCases = [ + ['flat global plus provider extra', { proxied: true }, { extra: { comment: 'home' } }, { comment: 'home', proxied: true }], + ['nested global plus flat provider field', { extra: { proxied: true } }, { comment: 'home' }, { proxied: true, comment: 'home' }], + ['explicit empty provider override', { extra: { proxied: true } }, { extra: {} }, {}], + ['nonempty provider override', { extra: { proxied: true } }, { extra: { proxied: false } }, { proxied: false }], + ['flat aliases override nested fields', { extra_proxied: true }, { extra: { proxied: false } }, { proxied: true }], + ['flat alias order survives cross-scope overrides', { proxied: true, extra_proxied: false }, { proxied: true }, { proxied: false }], + ['nested custom objects flatten before inheritance', { settings: { first: 1, second: 2 } }, { settings: { first: 3 } }, { settings_first: 3, settings_second: 2 }], +] +for (const [name, global, provider, expected] of extraCases) { + test(`extra round trip: ${name}`, async () => { + const page = await importApply({ ...base, ...global, providers: [{ ...base.providers[0], ...provider }] }) + assert.deepEqual(JSON.parse(page.get('selectedProvider.extraText')), expected) + assert.deepEqual((await page.copy()).providers[0].extra, expected) + assert.deepEqual((await page.download()).providers[0].extra, expected) + // Change an unrelated visible field so the normal draft lifecycle saves this state. + await page.click('selectSection(section.key)', { section: { key: 'records' } }) + await page.input('selectedProvider.ipv4Text', 'draft.example.com') + const { page: restored } = await page.refresh() + assert.deepEqual(restored.json().providers[0].extra, expected) + }) +} + +test('providers without custom fields keep inheriting global extra', async () => { + const page = await importApply({ ...base, proxied: true }) + assert.equal(page.get('selectedProvider.extraText'), '') + assert(!('extra' in page.json().providers[0])) + assert.deepEqual(page.json().extra, { proxied: true }) +}) + +test('empty extra text inherits, while an explicit object remains an override', async () => { + const page = await importApply({ ...base, extra: { proxied: true }, providers: [{ ...base.providers[0], extra: {} }] }) + await page.click('selectSection(section.key)', { section: { key: 'runtime' } }) + await page.input('selectedProvider.extraText', '') + assert(!('extra' in page.json().providers[0])) + await page.input('selectedProvider.extraText', '{}') + assert.deepEqual(page.json().providers[0].extra, {}) + await page.input('globalState.extraText', '{}') + assert.deepEqual(page.json().extra, {}) +}) + +test('new, duplicated and changed providers do not retain previous custom overrides', async () => { + const page = await importApply({ ...base, extra: { proxied: true }, providers: [{ ...base.providers[0], extra: {} }] }) + await page.click('duplicateProvider') + assert.equal(page.get('selectedProvider.extraText'), '') + assert(!('extra' in page.json().providers[1])) + assert(!('token' in page.json().providers[1])) + await page.click('addProvider') + assert(!('extra' in page.json().providers[2])) + await page.click('selectSection(section.key)', { section: { key: 'runtime' } }) + await page.input('selectedProvider.extraText', '{"comment":"discard on provider change"}') + await page.click('selectSection(section.key)', { section: { key: 'provider' } }) + await page.click('toggleProviderPicker') + await page.click('chooseProvider(provider.value)', { provider: { value: 'cloudflare' } }) + assert.equal(page.get('selectedProvider.extraText'), '') + await page.click('resetBuilder') + assert.equal(page.get('hasUnsavedChanges'), false) + assert.equal(page.get('selectedProvider.extraText'), '') +}) + +test('legacy single-provider imports retain extra values and absence', async () => { + const legacy = { dns: 'cloudflare', token: 'offline-placeholder', ipv4: ['home.example.com'] } + const inherited = await importApply(legacy) + assert(!('extra' in inherited.json().providers[0])) + const explicit = await importApply({ ...legacy, extra: {} }) + assert.deepEqual(explicit.json().providers[0].extra, {}) + const fields = await importApply({ ...legacy, extra: { proxied: false }, extra_proxied: true }) + assert.deepEqual(fields.json().providers[0].extra, { proxied: true }) +}) diff --git a/tests/e2e.py b/tests/e2e.py index 0e1f81e..32e15fe 100644 --- a/tests/e2e.py +++ b/tests/e2e.py @@ -163,7 +163,13 @@ class _FixtureHandler(BaseHTTPRequestHandler): self._send(200, "current address: {}\n".format(TEST_IPV4)) elif path == "/ip/v6": self._send(200, "{}\n".format(TEST_IPV6)) - elif path == "/callback/fail": + elif path == "/ip/error-address": + self._send(403, "upstream 198.51.100.20 unavailable") + elif path == "/ip/mixed-v6": + self._send(200, "2001:db8::192.0.2.10") + elif path == "/ip/compact-v6": + self._send(200, "IP:{}".format(TEST_IPV6)) + elif path in ("/callback/fail", "/callback/fail.example.com"): self._send(400, "callback rejected") elif path.startswith("/callback/"): self._send(200, "callback accepted") @@ -173,7 +179,7 @@ class _FixtureHandler(BaseHTTPRequestHandler): def do_POST(self): body = self._read_body() path = self._record(body) - if path == "/callback/fail": + if path in ("/callback/fail", "/callback/fail.example.com"): self._send(400, "callback rejected") elif path.startswith("/callback/"): self._send(200, "callback accepted") @@ -408,6 +414,92 @@ class OfflineE2ETestCase(unittest.TestCase): class TestCliE2E(OfflineE2ETestCase): """Exercise complete DDNS update flows through public process entrypoints.""" + def test_ipv6_runs_when_ipv4_is_disabled_or_fails(self): + """Update the working family independently of a disabled or unavailable IPv4 source.""" + for name, index4, expected_exit in ( + ("disabled", False, 0), + ("unavailable", ["url:" + self.fixture_url + "/ip/invalid"], 1), + ): + self.fixture_state.reset() + config = self._callback_config( + "/callback/dual-failure?type=__RECORDTYPE__&ip=__IP__", ["v4.example.com"], ["v6.example.com"] + ) + config["index4"] = index4 + config_path = self._write_config(name + ".json", config) + + result = self._run(["--config", config_path]) + + self.assertEqual(result.returncode, expected_exit, result.stderr) + requests = self.fixture_state.requests_for("/callback/dual-failure") + self.assertEqual(len(requests), 1, result.stderr) + self.assertEqual(requests[0]["query"], {"type": ["AAAA"], "ip": [TEST_IPV6]}) + self.assertEqual(len(self.fixture_state.requests_for("/ip/v6")), 1) + + def test_partial_domain_failure_preserves_only_successful_cache(self): + """Report partial failure and retry the rejected domain, while retaining successful cached records.""" + config = self._callback_config("/callback/__DOMAIN__", ["ok.example.com", "fail.example.com"], cache=True) + config_path = self._write_config("partial-domains.json", config) + + for attempt in (1, 2): + result = self._run(["--config", config_path]) + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(len(self.fixture_state.requests_for("/callback/ok.example.com")), 1) + self.assertEqual(len(self.fixture_state.requests_for("/callback/fail.example.com")), attempt) + + def test_http_error_address_is_ignored(self): + """Do not update DNS from the address contained in an IP endpoint error page.""" + config = self._callback_config("/callback/error-address?ip=__IP__", ["ip.example.com"]) + config["index4"] = ["url:" + self.fixture_url + "/ip/error-address", "url:" + self.fixture_url + "/ip/v4"] + config_path = self._write_config("error-address.json", config) + + result = self._run(["--config", config_path]) + + self.assert_process_success(result) + requests = self.fixture_state.requests_for("/callback/error-address") + self.assertEqual(len(requests), 1) + self.assertEqual(requests[0]["query"]["ip"], [TEST_IPV4]) + self.assertEqual(len(self.fixture_state.requests_for("/ip/error-address")), 1) + self.assertEqual(len(self.fixture_state.requests_for("/ip/v4")), 1) + + def test_mixed_ipv6_address_is_preserved(self): + """Forward a complete mixed-notation IPv6 address to the callback.""" + config = self._callback_config("/callback/mixed-v6?ip=__IP__", ipv6_domains=["mixed.example.com"]) + config["index6"] = ["url:" + self.fixture_url + "/ip/mixed-v6"] + config_path = self._write_config("mixed-v6.json", config) + + result = self._run(["--config", config_path]) + + self.assert_process_success(result) + requests = self.fixture_state.requests_for("/callback/mixed-v6") + self.assertEqual(len(requests), 1) + self.assertEqual(requests[0]["query"]["ip"], ["2001:db8::192.0.2.10"]) + + def test_compact_ipv6_label_is_preserved(self): + """Resolve a compact text response over real HTTP and forward its IPv6 address.""" + config = self._callback_config("/callback/compact-v6?ip=__IP__", ipv6_domains=["label.example.com"]) + config["index6"] = ["url:" + self.fixture_url + "/ip/compact-v6"] + config_path = self._write_config("compact-v6.json", config) + + result = self._run(["--config", config_path]) + + self.assert_process_success(result) + requests = self.fixture_state.requests_for("/callback/compact-v6") + self.assertEqual(len(requests), 1) + self.assertEqual(requests[0]["query"]["ip"], [TEST_IPV6]) + self.assertEqual(len(self.fixture_state.requests_for("/ip/compact-v6")), 1) + + def test_callback_http_failure_is_not_cached(self): + """Retry a missing callback endpoint instead of caching its error body as a successful update.""" + config = self._callback_config("/missing", ["callback.example.com"], cache=True) + config_path = self._write_config("missing-callback.json", config) + + for attempt in (1, 2): + result = self._run(["--config", config_path]) + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(len(self.fixture_state.requests_for("/missing")), attempt) + def test_cli_only_dual_stack_and_rule_fallback(self): """Resolve both address families and fall back after an unusable rule.""" result = self._run( @@ -706,6 +798,23 @@ class TestMcpE2E(OfflineE2ETestCase): ) return result, [json.loads(line) for line in lines] + def test_partial_domain_failure_is_a_tool_error(self): + """Report a rejected domain through MCP and retry it without repeating successful writes.""" + config = self._callback_config("/callback/__DOMAIN__", ["ok.example.com", "fail.example.com"], cache=True) + config_path = self._write_config("partial-mcp.json", config) + requests = [ + self._modern_request(request_id, "tools/call", {"name": "update_dns_records", "arguments": {}}) + for request_id in (1, 2) + ] + + _, responses = self._run_mcp(config_path, requests) + + for response in responses: + self.assertTrue(response["result"]["isError"]) + self.assertIn("Synchronization failed", response["result"]["content"][0]["text"]) + self.assertEqual(len(self.fixture_state.requests_for("/callback/ok.example.com")), 1) + self.assertEqual(len(self.fixture_state.requests_for("/callback/fail.example.com")), 2) + def test_modern_discovery_tools_update_and_cached_status(self): """Discover tools, synchronize dual-stack records, and read cached status.""" token = json.dumps( diff --git a/tests/test_ip.py b/tests/test_ip.py index 6c66464..98dab61 100644 --- a/tests/test_ip.py +++ b/tests/test_ip.py @@ -3,7 +3,7 @@ Tests for ddns.ip module including integration tests """ -from __init__ import unittest, patch, MagicMock +from __init__ import unittest, patch from ddns import ip from ddns.__main__ import get_ip from ddns.util.http import HttpResponse @@ -24,9 +24,7 @@ class TestIpModule(unittest.TestCase): def test_url_v4_success(self, mock_request): """测试自定义URL获取IPv4 - 成功""" # 模拟成功响应 - mock_response = MagicMock() - mock_response.body = "1.2.3.4" - mock_request.return_value = mock_response + mock_request.return_value = HttpResponse(200, "OK", {}, "1.2.3.4") result = ip.public_v4("https://test.example.com/ip") @@ -37,9 +35,7 @@ class TestIpModule(unittest.TestCase): def test_url_v6_success(self, mock_request): """测试自定义URL获取IPv6 - 成功""" # 模拟成功响应 - mock_response = MagicMock() - mock_response.body = "2001:db8::1" - mock_request.return_value = mock_response + mock_request.return_value = HttpResponse(200, "OK", {}, "2001:db8::1") result = ip.public_v6("https://test.example.com/ipv6") @@ -61,9 +57,7 @@ class TestIpModule(unittest.TestCase): def test_url_v4_invalid_response(self, mock_request): """测试自定义URL获取IPv4 - 无效响应""" # 模拟无效响应 - mock_response = MagicMock() - mock_response.body = "invalid response" - mock_request.return_value = mock_response + mock_request.return_value = HttpResponse(200, "OK", {}, "invalid response") result = ip.public_v4("https://test.example.com/ip") @@ -74,9 +68,7 @@ class TestIpModule(unittest.TestCase): def test_public_v4_multiple_apis_first_success(self, mock_request): """测试公网IPv4获取 - 多个API第一个成功""" # 模拟第一个API成功 - mock_response = MagicMock() - mock_response.body = "1.2.3.4" - mock_request.return_value = mock_response + mock_request.return_value = HttpResponse(200, "OK", {}, "1.2.3.4") result = ip.public_v4() @@ -91,10 +83,7 @@ class TestIpModule(unittest.TestCase): def mock_request_side_effect(method, url, **kwargs): if url == ip.PUBLIC_IPV4_APIS[0]: raise Exception("First API failed") - else: - mock_response = MagicMock() - mock_response.body = "1.2.3.4" - return mock_response + return HttpResponse(200, "OK", {}, "1.2.3.4") mock_request.side_effect = mock_request_side_effect @@ -122,9 +111,7 @@ class TestIpModule(unittest.TestCase): def test_public_v6_multiple_apis_first_success(self, mock_request): """测试公网IPv6获取 - 多个API第一个成功""" # 模拟第一个API成功 - mock_response = MagicMock() - mock_response.body = "2001:db8::1" - mock_request.return_value = mock_response + mock_request.return_value = HttpResponse(200, "OK", {}, "2001:db8::1") result = ip.public_v6() @@ -139,10 +126,7 @@ class TestIpModule(unittest.TestCase): def mock_request_side_effect(method, url, **kwargs): if url == ip.PUBLIC_IPV6_APIS[0]: raise Exception("First API failed") - else: - mock_response = MagicMock() - mock_response.body = "2001:db8::1" - return mock_response + return HttpResponse(200, "OK", {}, "2001:db8::1") mock_request.side_effect = mock_request_side_effect @@ -184,9 +168,7 @@ class TestIpModule(unittest.TestCase): if url == ip.PUBLIC_IPV4_APIS[0]: raise Exception("First API failed") elif url == ip.PUBLIC_IPV4_APIS[1]: - mock_response = MagicMock() - mock_response.body = "1.2.3.4" - return mock_response + return HttpResponse(200, "OK", {}, "1.2.3.4") else: raise Exception("Unexpected URL") @@ -203,9 +185,7 @@ class TestIpModule(unittest.TestCase): def test_get_ip_url_mode_backward_compatibility(self, mock_request): """测试通过get_ip使用url:模式的向后兼容性""" # 模拟成功响应 - mock_response = MagicMock() - mock_response.body = "1.2.3.4" - mock_request.return_value = mock_response + mock_request.return_value = HttpResponse(200, "OK", {}, "1.2.3.4") # 使用"url:"规则获取IPv4地址 result = get_ip("4", ["url:https://custom.api.com/ip"]) @@ -221,14 +201,13 @@ class TestIpModule(unittest.TestCase): """测试get_ip在多个URL规则之间回退""" def mock_request_side_effect(method, url, **kwargs): - mock_response = MagicMock() if url == "https://primary.example.com/ip": - mock_response.body = "invalid response" + body = "invalid response" elif url == "https://backup.example.com/ip": - mock_response.body = "1.2.3.4" + body = "1.2.3.4" else: raise Exception("Unexpected URL") - return mock_response + return HttpResponse(200, "OK", {}, body) mock_request.side_effect = mock_request_side_effect @@ -242,14 +221,13 @@ class TestIpModule(unittest.TestCase): """测试get_ip在规则返回空结果时继续尝试下一条""" def mock_request_side_effect(method, url, **kwargs): - mock_response = MagicMock() if url in ip.PUBLIC_IPV4_APIS: - mock_response.body = "invalid response" + body = "invalid response" elif url == "https://backup.api.com/ip": - mock_response.body = "1.2.3.4" + body = "1.2.3.4" else: raise Exception("Unexpected URL") - return mock_response + return HttpResponse(200, "OK", {}, body) mock_request.side_effect = mock_request_side_effect @@ -369,14 +347,13 @@ class TestIpModule(unittest.TestCase): """测试IPv6规则链在空结果时继续回退""" def mock_request_side_effect(method, url, **kwargs): - mock_response = MagicMock() if url == "https://primary.example.com/ipv6": - mock_response.body = "invalid response" + body = "invalid response" elif url == "https://backup.example.com/ipv6": - mock_response.body = "2001:db8::1" + body = "2001:db8::1" else: raise Exception("Unexpected URL") - return mock_response + return HttpResponse(200, "OK", {}, body) mock_request.side_effect = mock_request_side_effect @@ -385,6 +362,75 @@ class TestIpModule(unittest.TestCase): self.assertEqual(result, "2001:db8::1") self.assertEqual(mock_request.call_count, 2) + @patch("ddns.ip.request") + def test_error_response_address_is_not_used(self, mock_request): + """Do not treat an address in an HTTP failure body as a discovery result.""" + for status in (302, 403, 404, 429, 500, 503): + mock_request.return_value = HttpResponse(status, "request failed", {}, "upstream 198.51.100.20 failed") + + self.assertIsNone(ip.public_v4("https://test.example.com/ip"), str(status)) + + @patch("ddns.ip.request") + def test_public_apis_fall_back_after_http_error(self, mock_request): + """HTTP errors must advance to the next IPv4 or IPv6 discovery endpoint.""" + for public, failed_address, expected_address in ( + (ip.public_v4, "198.51.100.20", "192.0.2.10"), + (ip.public_v6, "2001:db8::bad", "2001:db8::10"), + ): + mock_request.reset_mock() + mock_request.side_effect = [ + HttpResponse(503, "Unavailable", {}, "upstream {} unavailable".format(failed_address)), + HttpResponse(200, "OK", {}, expected_address), + ] + + self.assertEqual(public(), expected_address) + self.assertEqual(mock_request.call_count, 2) + + @patch("ddns.ip.request") + def test_ipv6_literals_are_not_truncated(self, mock_request): + """Keep compressed, uncompressed, and embedded-IPv4 literals complete.""" + for address in ( + "::", + "::1", + "2001:db8::1", + "2001:db8:0:1:2:3:4:5", + "2001:db8::192.0.2.10", + "::ffff:192.0.2.10", + "::192.0.2.10", + "2001:db8:0:1:2:3:192.0.2.10", + "2001:db8:0:1::192.0.2.10", + "2001:db8:0:1:2::192.0.2.10", + "2001:db8::1:2:192.0.2.10", + "2001:DB8::192.0.2.10", + ): + mock_request.return_value = HttpResponse(200, "OK", {}, "Address: {}.".format(address)) + + self.assertEqual(ip.public_v6("https://test.example.com/ip"), address) + + @patch("ddns.ip.request") + def test_ipv6_compact_labels_preserve_complete_addresses(self, mock_request): + """A text-label colon is a separator, not part of the IPv6 literal.""" + for label in ("IP:", "address:"): + for address in ("2001:db8::1", "::1", "::ffff:192.0.2.10", "2001:db8::192.0.2.10"): + mock_request.return_value = HttpResponse(200, "OK", {}, label + address) + + self.assertEqual(ip.public_v6("https://test.example.com/ip"), address, label + address) + + @patch("ddns.ip.request") + def test_invalid_ipv6_literal_does_not_yield_a_valid_fragment(self, mock_request): + """A malformed address must not be replaced with one of its valid-looking fragments.""" + for address in ( + "2001:db8::192.0.2.999", + "2001:db8:0:0:0:0:0:0:1", + "2001:db8:::1", + ":2001:db8::1", + "2001:db8::1:", + ":::1", + ): + mock_request.return_value = HttpResponse(200, "OK", {}, address) + + self.assertIsNone(ip.public_v6("https://test.example.com/ip"), address) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py index ab3742b..cd7e2bc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -31,6 +31,145 @@ class TestMain(unittest.TestCase): mock_cache_new.assert_called_once_with(True, config.md5(), __main__.logger, 86400) self.assertEqual(mock_update_ip.call_count, 2) + @patch.object(__main__, "get_ip", side_effect=[None, "2001:db8::1"]) + @patch.object(__main__.Cache, "new", return_value=None) + @patch.object(__main__, "get_provider_class") + def test_run_attempts_ipv6_after_ipv4_failure(self, mock_provider_class, mock_cache_new, mock_get_ip): + """An unavailable IPv4 address must not prevent an IPv6 update.""" + provider = MagicMock() + provider.set_record.return_value = True + mock_provider_class.return_value = lambda *args, **kwargs: provider + config = Config( + cli_config={ + "dns": "debug", + "ipv4": ["v4.example.com"], + "ipv6": ["v6.example.com"], + "index4": ["default"], + "index6": ["default"], + } + ) + + self.assertFalse(__main__.run(config)) + + self.assertEqual([call[0][0] for call in mock_get_ip.call_args_list], ["4", "6"]) + provider.set_record.assert_called_once() + self.assertEqual(provider.set_record.call_args[0], ("v6.example.com", "2001:db8::1")) + self.assertEqual(provider.set_record.call_args[1]["record_type"], "AAAA") + + @patch.object(__main__, "_get_ip_from_rule", return_value="2001:db8::1") + @patch.object(__main__.Cache, "new", return_value=None) + @patch.object(__main__, "get_provider_class") + def test_run_disabled_ipv4_does_not_block_ipv6(self, mock_provider_class, mock_cache_new, mock_get_rule): + """Keeping disabled IPv4 domains must not disable the configured IPv6 family.""" + provider = MagicMock() + provider.set_record.return_value = True + mock_provider_class.return_value = lambda *args, **kwargs: provider + config = Config( + cli_config={ + "dns": "debug", + "ipv4": ["v4.example.com"], + "ipv6": ["v6.example.com"], + "index4": False, + "index6": ["default"], + } + ) + + self.assertTrue(__main__.run(config)) + + mock_get_rule.assert_called_once_with("6", "default") + provider.set_record.assert_called_once() + self.assertEqual(provider.set_record.call_args[1]["record_type"], "AAAA") + + @patch.object(__main__, "get_ip", return_value=False) + def test_update_ip_skips_disabled_family(self, mock_get_ip): + """An explicitly disabled family is skipped without discovery or writes.""" + provider = MagicMock() + + result = __main__.update_ip( + provider, None, False, ["disabled.example.com"], "AAAA", Config(cli_config={"dns": "debug"}) + ) + + self.assertIsNone(result) + mock_get_ip.assert_not_called() + provider.set_record.assert_not_called() + + @patch.object(__main__, "get_ip", return_value="192.0.2.1") + def test_update_ip_reports_partial_failures_and_continues(self, mock_get_ip): + """Later success must not hide an earlier failure, or stop subsequent domains.""" + config = Config(cli_config={"dns": "debug"}) + for outcomes in ( + [True, False, True], + [False, True], + [True, RuntimeError("update rejected"), True], + [RuntimeError("update rejected"), True], + [None, True], + ): + provider = MagicMock() + provider.set_record.side_effect = outcomes + domains = ["record{}.example.com".format(index) for index in range(len(outcomes))] + cache = {} + + result = __main__.update_ip(provider, cache, ["default"], domains, "A", config) + + self.assertFalse(result, repr(outcomes)) + self.assertEqual(provider.set_record.call_count, len(domains)) + self.assertEqual( + cache, + {"{}:A".format(domain): "192.0.2.1" for domain, outcome in zip(domains, outcomes) if outcome is True}, + ) + + @patch.object(__main__, "get_ip", return_value="192.0.2.1") + def test_cached_success_does_not_mask_failed_update(self, mock_get_ip): + """A cached record remains valid without making a different failed record successful.""" + provider = MagicMock() + provider.set_record.return_value = False + cache = {"cached.example.com:A": "192.0.2.1"} + + result = __main__.update_ip( + provider, + cache, + ["default"], + ["cached.example.com", "failed.example.com"], + "A", + Config(cli_config={"dns": "debug"}), + ) + + self.assertFalse(result) + provider.set_record.assert_called_once() + self.assertEqual(cache, {"cached.example.com:A": "192.0.2.1"}) + + @patch.object(__main__, "get_ip", return_value="192.0.2.1") + def test_update_ip_all_successes_populate_cache(self, mock_get_ip): + """All successful records retain the existing cache behavior.""" + provider = MagicMock() + provider.set_record.return_value = True + cache = {} + domains = ["first.example.com", "second.example.com"] + + self.assertTrue( + __main__.update_ip(provider, cache, ["default"], domains, "A", Config(cli_config={"dns": "debug"})) + ) + self.assertEqual(provider.set_record.call_count, 2) + self.assertEqual(cache, {domain + ":A": "192.0.2.1" for domain in domains}) + + @patch.object(__main__, "get_ip", return_value="192.0.2.1") + @patch.object(__main__.Cache, "new", return_value=None) + @patch.object(__main__, "get_provider_class") + def test_main_reports_partial_domain_failure(self, mock_provider_class, mock_cache_new, mock_get_ip): + """The process result must report any rejected domain, not just total failures.""" + provider = MagicMock() + provider.set_record.side_effect = [True, False] + mock_provider_class.return_value = lambda *args, **kwargs: provider + config = Config(cli_config={"dns": "debug", "ipv4": ["first.example.com", "second.example.com"]}) + + with patch.object(__main__, "load_configs", return_value=[config]): + with patch.object(sys, "stdout", io.StringIO()): + with self.assertRaises(SystemExit) as context: + __main__.main() + + self.assertEqual(context.exception.code, 1) + self.assertEqual(provider.set_record.call_count, 2) + @patch.object(__main__, "get_ip", return_value="192.0.2.1") def test_update_ip_stops_before_next_domain_when_cancelled(self, mock_get_ip): """Stop cooperative updates between configured DNS records.""" diff --git a/tests/test_provider_base_simple.py b/tests/test_provider_base_simple.py index d352f8b..6feac10 100644 --- a/tests/test_provider_base_simple.py +++ b/tests/test_provider_base_simple.py @@ -5,8 +5,9 @@ Unit tests for SimpleProvider @author: GitHub Copilot """ -from base_test import BaseProviderTestCase, unittest, MagicMock +from base_test import BaseProviderTestCase, unittest, MagicMock, patch from ddns.provider._base import SimpleProvider, TYPE_FORM, encode_params +from ddns.util.http import HttpResponse class _TestableSimpleProvider(SimpleProvider): @@ -222,6 +223,46 @@ class _TestableSimpleProviderClass(BaseProviderTestCase): self.assertTrue(result) +class TestSimpleProviderHTTPStatus(BaseProviderTestCase): + """A provider may opt in to strict HTTP status checks without changing the default.""" + + def setUp(self): + super(TestSimpleProviderHTTPStatus, self).setUp() + self.provider = _TestableSimpleProvider(self.id, self.token) + self.mock_logger(self.provider) + + def test_default_preserves_non_fatal_status_responses(self): + for status in (199, 300, 304, 404, 408, 409, 422, 429): + response = HttpResponse(status, "Test response", {}, '{"error": "Provider-specific result"}') + with patch("ddns.provider._base.request", return_value=response): + self.assertEqual(self.provider._http("GET", "/test"), {"error": "Provider-specific result"}) + + def test_default_keeps_existing_fatal_errors(self): + for status in (400, 401, 403, 500, 502, 503): + response = HttpResponse(status, "Test response", {}, '{"error": "Failure"}') + with patch("ddns.provider._base.request", return_value=response): + with self.assertRaises(RuntimeError) as context: + self.provider._http("GET", "/test") + self.assertIn(str(status), str(context.exception)) + + def test_strict_status_rejects_non_2xx_responses(self): + self.provider._require_http_success = True + for status in (199, 300, 304, 400, 401, 403, 404, 408, 409, 422, 429, 500): + response = HttpResponse(status, "Test response", {}, '{"error": "Failure"}') + with patch("ddns.provider._base.request", return_value=response): + with self.assertRaises(RuntimeError) as context: + self.provider._http("GET", "/test") + self.assertIn(str(status), str(context.exception)) + + def test_strict_status_accepts_2xx_and_empty_bodies(self): + self.provider._require_http_success = True + self.provider.decode_response = False + for status in (200, 201, 204, 299): + response = HttpResponse(status, "Success", {}, "") + with patch("ddns.provider._base.request", return_value=response): + self.assertEqual(self.provider._http("GET", "/test"), "") + + class _TestableSimpleProviderWithNoAPI(SimpleProvider): """Test implementation without API defined""" diff --git a/tests/test_provider_callback.py b/tests/test_provider_callback.py index 7ac11ba..1604adb 100644 --- a/tests/test_provider_callback.py +++ b/tests/test_provider_callback.py @@ -7,12 +7,19 @@ Unit tests for CallbackProvider import os import sys +import json import logging import random import platform +import shutil +import tempfile from time import sleep from base_test import BaseProviderTestCase, is_network_error, patch, unittest +from ddns.__main__ import update_ip +from ddns.cache import Cache +from ddns.config import Config from ddns.provider.callback import CallbackProvider +from ddns.util.http import HttpResponse class TestCallbackProvider(BaseProviderTestCase): @@ -272,6 +279,114 @@ class TestCallbackProvider(BaseProviderTestCase): provider.set_record("example.com", "192.168.1.1") +class TestCallbackProviderHTTPResponses(BaseProviderTestCase): + """Use real response handling and isolated on-disk caches without network requests.""" + + def setUp(self): + super(TestCallbackProviderHTTPResponses, self).setUp() + self.temp_dir = tempfile.mkdtemp(prefix="ddns-callback-test-") + self.addCleanup(shutil.rmtree, self.temp_dir) + self.cache_path = os.path.join(self.temp_dir, "records.cache") + # Keep file timestamps aligned with the clock, including on coarse Windows clocks. + self.cache_timestamp = 1700000000 + cache_clock = patch("ddns.cache.time", return_value=self.cache_timestamp) + cache_clock.start() + self.addCleanup(cache_clock.stop) + self.domain = "www.example.com" + self.address = "192.0.2.9" + self.url = "https://example.invalid/update?domain=__DOMAIN__&ip=__IP__" + self.post_body = '{"domain": "__DOMAIN__", "value": "__IP__"}' + + def _sync_once(self, status, body, token): + config = Config( + json_config={ + "dns": "callback", + "id": self.url, + "token": token, + "ipv4": [self.domain], + "cache": self.cache_path, + } + ) + provider = CallbackProvider(config.id, config.token) + self.mock_logger(provider) + cache = Cache.new(config.cache, config.md5(), provider.logger, config.cache_max_age) + try: + response = HttpResponse(status, "Test response", {}, body) + with patch("ddns.__main__.get_ip", return_value=self.address), patch( + "ddns.provider._base.request", return_value=response + ) as mock_request: + result = update_ip(provider, cache, ["public"], [self.domain], "A", config) + return result, list(mock_request.call_args_list) + finally: + cache.close() + os.utime(self.cache_path, (self.cache_timestamp, self.cache_timestamp)) + + def _read_cache(self): + with open(self.cache_path, "r") as cache_file: + return json.load(cache_file) + + def _assert_failure_retries(self, status, token): + result, calls = self._sync_once(status, "Nonempty error response", token) + self.assertFalse(result) + self.assertEqual([call[0][0] for call in calls], ["POST" if token else "GET"]) + self.assertEqual(self._read_cache(), {}) + + result, calls = self._sync_once(204, "", token) + self.assertTrue(result) + self.assertEqual(len(calls), 1) + self.assertEqual(self._read_cache(), {self.domain + ":A": self.address}) + + result, calls = self._sync_once(429, "Must not be requested after success", token) + self.assertTrue(result) + self.assertEqual(calls, []) + + def test_get_404_is_not_cached(self): + self._assert_failure_retries(404, "") + + def test_post_404_is_not_cached(self): + self._assert_failure_retries(404, self.post_body) + + def test_get_429_is_not_cached(self): + self._assert_failure_retries(429, "") + + def test_post_429_is_not_cached(self): + self._assert_failure_retries(429, self.post_body) + + def test_get_400_is_not_cached(self): + self._assert_failure_retries(400, "") + + def test_post_400_is_not_cached(self): + self._assert_failure_retries(400, self.post_body) + + def test_empty_2xx_response_is_success(self): + provider = CallbackProvider(self.url, "") + self.mock_logger(provider) + for status in (200, 201, 204, 299): + with patch( + "ddns.provider._base.request", return_value=HttpResponse(status, "Success", {}, "") + ) as mock_request: + self.assertTrue(provider.set_record(self.domain, self.address)) + mock_request.assert_called_once() + + def test_other_non_2xx_responses_fail(self): + provider = CallbackProvider(self.url, "") + self.mock_logger(provider) + for status in (199, 300, 302, 304, 403, 409, 500): + with patch( + "ddns.provider._base.request", return_value=HttpResponse(status, "Failure", {}, "Error body") + ) as mock_request: + self.assertFalse(provider.set_record(self.domain, self.address)) + mock_request.assert_called_once() + + def test_http_200_business_error_body_is_not_interpreted(self): + provider = CallbackProvider(self.url, "") + self.mock_logger(provider) + body = '{"success": false, "error": "Custom application response"}' + with patch("ddns.provider._base.request", return_value=HttpResponse(200, "OK", {}, body)) as mock_request: + self.assertTrue(provider.set_record(self.domain, self.address)) + mock_request.assert_called_once() + + class TestCallbackProviderRealIntegration(BaseProviderTestCase): """Real integration tests for CallbackProvider using httpbin.org""" diff --git a/tests/test_provider_cloudflare.py b/tests/test_provider_cloudflare.py index 4f7362e..0f5a9bb 100644 --- a/tests/test_provider_cloudflare.py +++ b/tests/test_provider_cloudflare.py @@ -5,9 +5,17 @@ Unit tests for CloudflareProvider @author: GitHub Copilot """ -from base_test import BaseProviderTestCase, patch, unittest +import json +import os +import shutil +import tempfile +from base_test import BaseProviderTestCase, patch, unittest +from ddns.__main__ import update_ip +from ddns.cache import Cache +from ddns.config import Config from ddns.provider.cloudflare import CloudflareProvider +from ddns.util.http import HttpResponse class TestCloudflareProvider(BaseProviderTestCase): @@ -103,9 +111,10 @@ class TestCloudflareProvider(BaseProviderTestCase): with patch.object(provider, "_http") as mock_http: mock_http.return_value = {"success": False, "errors": ["Invalid API key"]} - result = provider._request("GET", "/test") + with self.assertRaises(RuntimeError): + provider._request("GET", "/test") - self.assertEqual(result, {"success": False, "errors": ["Invalid API key"]}) + mock_http.assert_called_once() def test_request_filters_none_params(self): """Test _request method filters out None parameters""" @@ -661,5 +670,143 @@ class TestCloudflareProviderIntegration(BaseProviderTestCase): # The workflow should work the same regardless of auth method +class TestCloudflareProviderHTTPResponses(BaseProviderTestCase): + """Exercise the real provider and disk cache with only transport/IP discovery mocked.""" + + def setUp(self): + super(TestCloudflareProviderHTTPResponses, self).setUp() + self.temp_dir = tempfile.mkdtemp(prefix="ddns-cloudflare-test-") + self.addCleanup(shutil.rmtree, self.temp_dir) + self.cache_path = os.path.join(self.temp_dir, "records.cache") + # Keep file timestamps aligned with the clock, including on coarse Windows clocks. + self.cache_timestamp = 1700000000 + cache_clock = patch("ddns.cache.time", return_value=self.cache_timestamp) + cache_clock.start() + self.addCleanup(cache_clock.stop) + self.domain = "www.example.com" + self.address = "192.0.2.9" + self.config = Config( + json_config={"dns": "cloudflare", "token": self.token, "ipv4": [self.domain], "cache": self.cache_path} + ) + self.api_error = {"success": False, "errors": [{"code": 1000, "message": "Request failed"}]} + + def _response(self, payload, status=200): + return HttpResponse(status, "Test response", {}, json.dumps(payload)) + + def _lookup_responses(self, existing): + records = [{"id": "rec123", "name": self.domain, "type": "A", "content": "192.0.2.1"}] if existing else [] + return [ + self._response({"success": True, "result": [{"id": "zone123", "name": "example.com"}]}), + self._response({"success": True, "result": records}), + ] + + def _sync_once(self, responses, extra=None): + provider = CloudflareProvider("", self.token) + self.mock_logger(provider) + self.config.extra = extra or {} + cache = Cache.new(self.config.cache, self.config.md5(), provider.logger, self.config.cache_max_age) + try: + with patch("ddns.__main__.get_ip", return_value=self.address), patch( + "ddns.provider._base.request", side_effect=responses + ) as mock_request: + result = update_ip(provider, cache, ["public"], [self.domain], "A", self.config) + return result, list(mock_request.call_args_list) + finally: + cache.close() + os.utime(self.cache_path, (self.cache_timestamp, self.cache_timestamp)) + + def _read_cache(self): + with open(self.cache_path, "r") as cache_file: + return json.load(cache_file) + + def _assert_write_failure_retries(self, existing, status): + responses = self._lookup_responses(existing) + [self._response(self.api_error, status)] + result, calls = self._sync_once(responses) + self.assertFalse(result) + self.assertEqual([call[0][0] for call in calls], ["GET", "GET", "PUT" if existing else "POST"]) + self.assertEqual(self._read_cache(), {}) + + # Reload the actual cache file: a failed write must not suppress the next attempt. + responses = self._lookup_responses(existing) + [self._response({"success": True, "result": {"id": "rec123"}})] + result, calls = self._sync_once(responses) + self.assertTrue(result) + self.assertEqual(len(calls), 3) + self.assertEqual(self._read_cache(), {self.domain + ":A": self.address}) + + # A successful retry, unlike the failure, may suppress a subsequent update. + result, calls = self._sync_once([]) + self.assertTrue(result) + self.assertEqual(calls, []) + + def test_create_rate_limit_is_not_cached(self): + self._assert_write_failure_retries(False, 429) + + def test_update_rate_limit_is_not_cached(self): + self._assert_write_failure_retries(True, 429) + + def test_create_api_failure_is_not_cached(self): + self._assert_write_failure_retries(False, 200) + + def test_update_api_failure_is_not_cached(self): + self._assert_write_failure_retries(True, 200) + + def test_create_http_400_is_not_cached(self): + self._assert_write_failure_retries(False, 400) + + def test_update_http_400_is_not_cached(self): + self._assert_write_failure_retries(True, 400) + + def test_http_failure_overrides_success_envelope(self): + responses = self._lookup_responses(True) + [self._response({"success": True, "result": {"id": "rec123"}}, 429)] + result, calls = self._sync_once(responses) + self.assertFalse(result) + self.assertEqual(len(calls), 3) + self.assertEqual(self._read_cache(), {}) + + def test_record_query_failure_does_not_create(self): + for payload, status in ((self.api_error, 200), (self.api_error, 429), ({}, 200)): + responses = self._lookup_responses(False)[:1] + [ + self._response(payload, status), + self._response({"success": True, "result": {"id": "unexpected-create"}}), + ] + result, calls = self._sync_once(responses) + self.assertFalse(result) + self.assertEqual([call[0][0] for call in calls], ["GET", "GET"]) + self.assertEqual(self._read_cache(), {}) + + def test_filtered_query_failure_does_not_fallback(self): + responses = self._lookup_responses(False)[:1] + [self._response(self.api_error)] + result, calls = self._sync_once(responses, extra={"proxied": False}) + self.assertFalse(result) + self.assertEqual(len(calls), 2) + self.assertEqual(self._read_cache(), {}) + + def test_zone_query_failure_does_not_try_another_zone(self): + result, calls = self._sync_once([self._response({})]) + self.assertFalse(result) + self.assertEqual(len(calls), 1) + self.assertEqual(self._read_cache(), {}) + + def test_successful_empty_record_list_creates(self): + responses = self._lookup_responses(False) + [self._response({"success": True, "result": {"id": "created"}})] + result, calls = self._sync_once(responses) + self.assertTrue(result) + self.assertEqual([call[0][0] for call in calls], ["GET", "GET", "POST"]) + + def test_successful_empty_zone_list_is_not_an_api_error(self): + provider = CloudflareProvider("", self.token) + self.mock_logger(provider) + with patch("ddns.provider._base.request", return_value=self._response({"success": True, "result": []})): + self.assertIsNone(provider._query_zone_id("example.com")) + + def test_non_object_api_response_raises(self): + provider = CloudflareProvider("", self.token) + self.mock_logger(provider) + for payload in (None, [], "Invalid response"): + with patch("ddns.provider._base.request", return_value=self._response(payload)): + with self.assertRaises(RuntimeError): + provider._request("GET", "") + + if __name__ == "__main__": unittest.main()