Repository: favonia/cloudflare-ddns
Pull request #966 (2024-10-05 14:55:39)

feat(provider): add undocumented debug.const provider

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/966?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
Attention: Patch coverage is `28.57143%` with `15 lines` in your changes missing coverage. Please review.
> Project coverage is 95.33%. Comparing base [(`619ae33`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/619ae331804753c2d36c6442974d0d9fefe2cd4f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) to head [(`40d4533`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/40d4533765e73d545fc4ca2383912e7f8c61bc69?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).

| [Files with missing lines](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/966?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Patch % | Lines |
|---|---|---|
| [internal/provider/const.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/966?src=pr&el=tree&filepath=internal%2Fprovider%2Fconst.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHJvdmlkZXIvY29uc3QuZ28=) | 0.00% | [9 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/966?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |
| [internal/config/env\_provider.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/966?src=pr&el=tree&filepath=internal%2Fconfig%2Fenv_provider.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2Vudl9wcm92aWRlci5nbw==) | 14.28% | [6 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/966?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #966      +/-   ##
==========================================
- Coverage   95.82%   95.33%   -0.50%     
==========================================
  Files          62       64       +2     
  Lines        2874     2893      +19     
==========================================
+ Hits         2754     2758       +4     
- Misses        108      123      +15     
  Partials       12       12              
```

| [Flag](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/966/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/966/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | `95.33% <28.57%> (-0.50%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/966?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).


Patch:
diff --git a/internal/config/env_provider.go b/internal/config/env_provider.go
index 044e317..b9fd5a3 100644
--- a/internal/config/env_provider.go
+++ b/internal/config/env_provider.go
@@ -123,8 +123,7 @@ func ReadProvider(ppfmt pp.PP, key, keyDeprecated string, field *provider.Provid
 			return false
 		}
 		ppfmt.Hintf(pp.HintExperimentalLocalWithInterface,
-			`You are using the experimental provider "local.iface:%s" added in version 1.15.0`,
-			parts[1])
+			`You are using the experimental "local.iface" provider added in version 1.15.0`)
 		*field = provider.NewLocalWithInterface(parts[1])
 		return true
 	case len(parts) == 2 && parts[0] == "url":
@@ -136,6 +135,21 @@ func ReadProvider(ppfmt pp.PP, key, keyDeprecated string, field *provider.Provid
 	case len(parts) == 1 && parts[0] == "none":
 		*field = nil
 		return true
+	case len(parts) == 2 && parts[0] == "debug.const":
+		ppfmt.Hintf(pp.HintDebugConstProvider, `You are using the undocumented "debug.const" provider`)
+		if parts[1] == "" {
+			ppfmt.Noticef(
+				pp.EmojiUserError,
+				`%s=debug.const: must be followed by an IP address`,
+				key,
+			)
+			return false
+		}
+		p, ok := provider.NewDebugConst(ppfmt, parts[1])
+		if ok {
+			*field = p
+		}
+		return ok
 	default:
 		ppfmt.Noticef(pp.EmojiUserError, "%s (%q) is not a valid provider", key, val)
 		return false
diff --git a/internal/pp/hint.go b/internal/pp/hint.go
index cf9368c..7ccc367 100644
--- a/internal/pp/hint.go
+++ b/internal/pp/hint.go
@@ -20,4 +20,5 @@ const (
 	HintExperimentalShoutrrr                       // New feature introduced in 1.12.0 on 2024/6/28
 	HintExperimentalWAF                            // New feature introduced in 1.14.0 on 2024/8/25
 	HintExperimentalLocalWithInterface             // New feature introduced in 1.15.0
+	HintDebugConstProvider                         // Undocumented feature
 )
diff --git a/internal/provider/const.go b/internal/provider/const.go
new file mode 100644
index 0000000..0dc90f1
--- /dev/null
+++ b/internal/provider/const.go
@@ -0,0 +1,33 @@
+package provider
+
+import (
+	"net/netip"
+	"strings"
+
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/provider/protocol"
+)
+
+// NewDebugConst creates a [protocol.Const] provider.
+func NewDebugConst(ppfmt pp.PP, raw string) (Provider, bool) {
+	ip, err := netip.ParseAddr(raw)
+	if err != nil {
+		ppfmt.Noticef(pp.EmojiUserError, `Failed to parse the IP address %q following "const:"`, raw)
+		return nil, false
+	}
+
+	return protocol.Const{
+		ProviderName: "debug.const:" + ip.String(),
+		IP:           ip,
+	}, true
+}
+
+// MustNewDebugConst creates a [protocol.Const] provider and panics if it fails.
+func MustNewDebugConst(raw string) Provider {
+	var buf strings.Builder
+	p, ok := NewDebugConst(pp.NewDefault(&buf), raw)
+	if !ok {
+		panic(buf.String())
+	}
+	return p
+}
diff --git a/internal/provider/protocol/const.go b/internal/provider/protocol/const.go
new file mode 100644
index 0000000..295f3ba
--- /dev/null
+++ b/internal/provider/protocol/const.go
@@ -0,0 +1,29 @@
+package protocol
+
+import (
+	"context"
+	"net/netip"
+
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+// Const returns the same IP.
+type Const struct {
+	// Name of the detection protocol.
+	ProviderName string
+
+	// The IP.
+	IP netip.Addr
+}
+
+// Name of the detection protocol.
+func (p Const) Name() string {
+	return p.ProviderName
+}
+
+// GetIP returns the IP.
+func (p Const) GetIP(_ context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, Method, bool) {
+	normalizedIP, ok := ipNet.NormalizeDetectedIP(ppfmt, p.IP)
+	return normalizedIP, MethodPrimary, ok
+}


Test patch:
diff --git a/internal/config/env_provider_test.go b/internal/config/env_provider_test.go
index 7f11398..c30928e 100644
--- a/internal/config/env_provider_test.go
+++ b/internal/config/env_provider_test.go
@@ -27,6 +27,7 @@ func TestReadProvider(t *testing.T) {
 		localLoopback = provider.NewLocalWithInterface("lo")
 		ipify         = provider.NewIpify()
 		custom        = provider.MustNewCustomURL("https://url.io")
+		debugConst    = provider.MustNewDebugConst("1.1.1.1")
 	)
 
 	for name, tc := range map[string]struct {
@@ -118,7 +119,7 @@ func TestReadProvider(t *testing.T) {
 		"local.iface:lo": {
 			true, "   local.iface   :  lo ", false, "", trace, localLoopback, true,
 			func(m *mocks.MockPP) {
-				m.EXPECT().Hintf(pp.HintExperimentalLocalWithInterface, `You are using the experimental provider "local.iface:%s" added in version 1.15.0`, "lo")
+				m.EXPECT().Hintf(pp.HintExperimentalLocalWithInterface, `You are using the experimental "local.iface" provider added in version 1.15.0`)
 			},
 		},
 		"local.iface:": {
@@ -140,6 +141,21 @@ func TestReadProvider(t *testing.T) {
 				m.EXPECT().Noticef(pp.EmojiUserError, "%s (%q) is not a valid provider", key, "something-else")
 			},
 		},
+		"debug.const:1.1.1.1": {
+			true, "   debug.const   :  1.1.1.1 ", false, "", trace, debugConst, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Hintf(pp.HintDebugConstProvider, `You are using the undocumented "debug.const" provider`)
+			},
+		},
+		"debug.const": {
+			true, "   debug.const: ", false, "", trace, trace, false,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Hintf(pp.HintDebugConstProvider, `You are using the undocumented "debug.const" provider`),
+					m.EXPECT().Noticef(pp.EmojiUserError, `%s=debug.const: must be followed by an IP address`, key),
+				)
+			},
+		},
 	} {
 		t.Run(name, func(t *testing.T) {
 			set(t, key, tc.set, tc.val)
diff --git a/internal/provider/const_test.go b/internal/provider/const_test.go
new file mode 100644
index 0000000..763709e
--- /dev/null
+++ b/internal/provider/const_test.go
@@ -0,0 +1,39 @@
+package provider_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/provider"
+)
+
+func TestDebugConstName(t *testing.T) {
+	t.Parallel()
+
+	require.Equal(t, "debug.const:1.1.1.1", provider.Name(provider.MustNewDebugConst("1.1.1.1")))
+}
+
+func TestMustDebugConst(t *testing.T) {
+	t.Parallel()
+
+	for _, tc := range []struct {
+		input string
+		ok    bool
+	}{
+		{"1.1.1.1", true},
+		{"1::1%1", true},
+		{"", false},
+		{"blah", false},
+	} {
+		t.Run(tc.input, func(t *testing.T) {
+			t.Parallel()
+
+			if tc.ok {
+				require.NotPanics(t, func() { provider.MustNewDebugConst(tc.input) })
+			} else {
+				require.Panics(t, func() { provider.MustNewDebugConst(tc.input) })
+			}
+		})
+	}
+}
diff --git a/internal/provider/protocol/const_test.go b/internal/provider/protocol/const_test.go
new file mode 100644
index 0000000..048f491
--- /dev/null
+++ b/internal/provider/protocol/const_test.go
@@ -0,0 +1,85 @@
+// vim: nowrap
+//go:build linux
+
+package protocol_test
+
+import (
+	"context"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/provider/protocol"
+)
+
+func TestConstName(t *testing.T) {
+	t.Parallel()
+
+	p := &protocol.Const{
+		ProviderName: "very secret name",
+		IP:           netip.Addr{},
+	}
+
+	require.Equal(t, "very secret name", p.Name())
+}
+
+func TestConstGetIP(t *testing.T) {
+	t.Parallel()
+
+	var invalidIP netip.Addr
+
+	for name, tc := range map[string]struct {
+		savedIP       netip.Addr
+		ipNet         ipnet.Type
+		ok            bool
+		expected      netip.Addr
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"valid/4": {
+			netip.MustParseAddr("1.1.1.1"), ipnet.IP4,
+			true, netip.MustParseAddr("1.1.1.1"), nil,
+		},
+		"valid/6": {
+			netip.MustParseAddr("1::1%1"), ipnet.IP6,
+			true, netip.MustParseAddr("1::1%1"), nil,
+		},
+		"error/invalid": {
+			invalidIP, ipnet.IP6,
+			false, invalidIP,
+			func(ppfmt *mocks.MockPP) {
+				ppfmt.EXPECT().Noticef(pp.EmojiImpossible, "Detected IP address is not valid; this should not happen and please report it at %s", pp.IssueReportingURL)
+			},
+		},
+		"error/6-as-4": {
+			netip.MustParseAddr("1::1%1"), ipnet.IP4,
+			false, invalidIP,
+			func(ppfmt *mocks.MockPP) {
+				ppfmt.EXPECT().Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv4 address", "1::1%1")
+			},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+
+			provider := &protocol.Const{
+				ProviderName: "",
+				IP:           tc.savedIP,
+			}
+			ip, method, ok := provider.GetIP(context.Background(), mockPP, tc.ipNet)
+			require.Equal(t, tc.ok, ok)
+			require.NotEqual(t, protocol.MethodAlternative, method)
+			require.Equal(t, tc.expected, ip)
+		})
+	}
+}