Repository: favonia/cloudflare-ddns (go)

Issue:
Support `CLOUDFLARE_*` environment variables
- `CF_API_TOKEN` and `CLOUDFLARE_API_TOKEN`
- `CF_API_TOKEN_FILE` and `CLOUDFLARE_API_TOKEN_FILE`

Pull request description:
feat(config): accept CLOUDFLARE_* and all compatible token settings
Close #872.

Patch:
diff --git a/README.markdown b/README.markdown
index 5bd2dfe..a0c0581 100644
--- a/README.markdown
+++ b/README.markdown
@@ -82,7 +82,7 @@ _(Click to expand the following items.)_
 ```bash
 docker run \
   --network host \
-  -e CF_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN \
+  -e CLOUDFLARE_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN \
   -e DOMAINS=example.org,www.example.org,example.io \
   -e PROXIED=true \
   favonia/cloudflare-ddns:latest
@@ -95,7 +95,7 @@ docker run \
 You need the [Go tool](https://golang.org/doc/install) to run the updater from its source.
 
 ```bash
-CF_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN \
+CLOUDFLARE_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN \
   DOMAINS=example.org,www.example.org,example.io \
   PROXIED=true \
   go run github.com/favonia/cloudflare-ddns/cmd/ddns@latest
@@ -132,7 +132,7 @@ services:
     security_opt: [no-new-privileges:true]
     # Another protection to restrict superuser privileges (optional but recommended)
     environment:
-      - CF_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN
+      - CLOUDFLARE_API_TOKEN=YOUR-CLOUDFLARE-API-TOKEN
         # Your Cloudflare API token
       - DOMAINS=example.org,www.example.org,example.io
         # Your domains (separated by commas)
@@ -143,15 +143,14 @@ services:
 _(Click to expand the following important tips.)_
 
 <details>
-<summary>🔑 <code>CF_API_TOKEN</code> is your Cloudflare API token</summary>
+<summary>🔑 <code>CLOUDFLARE_API_TOKEN</code> is your Cloudflare API token</summary>
 
-The value of `CF_API_TOKEN` should be an API **token** (_not_ an API key), which can be obtained from the [API Tokens page](https://dash.cloudflare.com/profile/api-tokens). (The less secure API key authentication is deliberately _not_ supported.)
+The value of `CLOUDFLARE_API_TOKEN` should be an API **token** (_not_ an API key), which can be obtained from the [API Tokens page](https://dash.cloudflare.com/profile/api-tokens). The less secure API key authentication is deliberately _not_ supported.
 
 - To update only DNS records, use the **Edit zone DNS** template to create a token.
 - To update only WAF lists, choose **Create Custom Token** and then add the **Account - Account Filter Lists - Edit** permission to create a token.
-- To update DNS records _and_ WAF lists, use the **Edit zone DNS** template and then add the **Account - Account Filter Lists - Edit** permission when creating the token.
-
-You can also adjust the permissions of existing tokens at any time!
+- To update _both_ DNS records _and_ WAF lists, use the **Edit zone DNS** template and then add the **Account - Account Filter Lists - Edit** permission when creating the token.
+- You can adjust the permissions of existing tokens at any time!
 
 </details>
 
@@ -259,15 +258,20 @@ _(Click to expand the following items.)_
 <details>
 <summary>🔑 The Cloudflare API token</summary>
 
-> Exactly one of the following variables should be set.
+> Starting with version 1.15.0, the updater supports environment variables that begin with `CLOUDFLARE_*`. Multiple environment variables can be used at the same time, provided they all specify the same token.
 
-| Name                | Meaning                                                                                                                                |
-| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
-| `CF_API_TOKEN`      | The [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens) to access the Cloudflare API                                |
-| `CF_API_TOKEN_FILE` | A path to a file that contains the [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens) to access the Cloudflare API |
+| Name                                                    | Meaning                                                                                                                                |
+| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `CLOUDFLARE_API_TOKEN`                                  | The [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens) to access the Cloudflare API                                |
+| `CLOUDFLARE_API_TOKEN_FILE`                             | A path to a file that contains the [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens) to access the Cloudflare API |
+| `CF_API_TOKEN` (will be deprecated in version 2.0)      | Same as `CLOUDFLARE_API_TOKEN`                                                                                                         |
+| `CF_API_TOKEN_FILE` (will be deprecated version in 2.0) | Same as `CLOUDFLARE_API_TOKEN_FILE`                                                                                                    |
 
-- 🔑 To update DNS records, the updater needs the **Account - Account Filter Lists - Edit** permission.
-- 🔑 To manipulate WAF lists, the updater needs the **Zone - DNS - Edit** permission.
+> 🚂 Cloudflare is updating its tools to use environment variables starting with `CLOUDFLARE_*` instead of `CF_*`. It is recommended to align your setting to align with this new convention. However, the updater will fully support both `CLOUDFLARE_*` and `CF_*` environment variables until version 2.0.
+>
+> 🔑 To update DNS records, the updater needs the **Account - Account Filter Lists - Edit** permission.
+>
+> 🔑 To manipulate WAF lists, the updater needs the **Zone - DNS - Edit** permission.
 
 </details>
 
@@ -423,8 +427,8 @@ _(Click to expand the following items.)_
 
 | Old Parameter                          |     | Note                                                                                                                                                                                                           |
 | -------------------------------------- | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `API_KEY=key`                          | ✔️  | Use `CF_API_TOKEN=key`                                                                                                                                                                                         |
-| `API_KEY_FILE=file`                    | ✔️  | Use `CF_API_TOKEN_FILE=file`                                                                                                                                                                                   |
+| `API_KEY=key`                          | ✔️  | Use `CLOUDFLARE_API_TOKEN=key`                                                                                                                                                                                 |
+| `API_KEY_FILE=file`                    | ✔️  | Use `CLOUDFLARE_API_TOKEN_FILE=file`                                                                                                                                                                           |
 | `ZONE=example.org` and `SUBDOMAIN=sub` | ✔️  | Use `DOMAINS=sub.example.org` directly                                                                                                                                                                         |
 | `PROXIED=true`                         | ✔️  | Same (`PROXIED=true`)                                                                                                                                                                                          |
 | `RRTYPE=A`                             | ✔️  | Both IPv4 and IPv6 are enabled by default; use `IP6_PROVIDER=none` to disable IPv6                                                                                                                             |
@@ -441,7 +445,7 @@ _(Click to expand the following items.)_
 
 | Old JSON Key                          |     | Note                                                                                                                                                                                                                                     |
 | ------------------------------------- | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `cloudflare.authentication.api_token` | ✔️  | Use `CF_API_TOKEN=key`                                                                                                                                                                                                                   |
+| `cloudflare.authentication.api_token` | ✔️  | Use `CLOUDFLARE_API_TOKEN=key`                                                                                                                                                                                                           |
 | `cloudflare.authentication.api_key`   | ❌  | Please use the newer, more secure [API tokens](https://dash.cloudflare.com/profile/api-tokens)                                                                                                                                           |
 | `cloudflare.zone_id`                  | ✔️  | Not needed; automatically retrieved from the server                                                                                                                                                                                      |
 | `cloudflare.subdomains[].name`        | ✔️  | Use `DOMAINS` with [**fully qualified domain names (FQDNs)**](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) directly; for example, if your zone is `example.org` and your subdomain is `sub`, use `DOMAINS=sub.example.org` |
diff --git a/internal/config/env_auth.go b/internal/config/env_auth.go
index 864d190..b0e4e33 100644
--- a/internal/config/env_auth.go
+++ b/internal/config/env_auth.go
@@ -10,48 +10,128 @@ import (
 
 var oauthBearerRegex = regexp.MustCompile(`^[-a-zA-Z0-9._~+/]+=*$`)
 
-func readAuthToken(ppfmt pp.PP) (string, bool) {
-	var (
-		token     = Getenv("CF_API_TOKEN")
-		tokenFile = Getenv("CF_API_TOKEN_FILE")
-	)
-	var ok bool
+// Keys of environment variables.
+const (
+	TokenKey1     string = "CLOUDFLARE_API_TOKEN"      //nolint:gosec
+	TokenKey2     string = "CF_API_TOKEN"              //nolint:gosec
+	TokenFileKey1 string = "CLOUDFLARE_API_TOKEN_FILE" //nolint:gosec
+	TokenFileKey2 string = "CF_API_TOKEN_FILE"         //nolint:gosec
+)
+
+// HintAuthTokenNewPrefix contains the hint about the transition from
+// CF_* to CLOUDFLARE_*.
+const HintAuthTokenNewPrefix string = "Cloudflare is transitioning its tools to use the prefix CLOUDFLARE instead of CF. To align with this change, it is recommended to use CLOUDFLARE_API_TOKEN (or CLOUDFLARE_API_TOKEN_FILE) instead of CF_API_TOKEN (or CF_API_TOKEN_FILE) moving forward. All options will be fully supported until version 2.0." //nolint:lll,gosec
+
+func readPlainAuthTokens(ppfmt pp.PP) (string, string, bool) {
+	token1 := Getenv(TokenKey1)
+	token2 := Getenv(TokenKey2)
+
+	var token, tokenKey string
+	switch {
+	case token1 == "" && token2 == "":
+		return "", "", true
+	case token1 != "" && token2 != "" && token1 != token2:
+		ppfmt.Noticef(pp.EmojiUserError,
+			"The values of %s and %s do not match; they must specify the same token", TokenKey1, TokenKey2)
+		return "", "", false
+	case token1 != "":
+		token, tokenKey = token1, TokenKey1
+	case token2 != "":
+		ppfmt.Hintf(pp.HintAuthTokenNewPrefix, HintAuthTokenNewPrefix)
+		token, tokenKey = token2, TokenKey2
+	}
 
-	// foolproof checks
+	// foolproof check: the sample value in README
 	if token == "YOUR-CLOUDFLARE-API-TOKEN" {
-		ppfmt.Noticef(pp.EmojiUserError, "You need to provide a real API token as CF_API_TOKEN")
+		ppfmt.Noticef(pp.EmojiUserError, "You need to provide a real API token as %s", tokenKey)
+		return "", "", false
+	}
+
+	return token, tokenKey, true
+}
+
+func readAuthTokenFile(ppfmt pp.PP, key string) (string, bool) {
+	tokenFile := Getenv(key)
+	if tokenFile == "" {
+		return "", true
+	}
+
+	token, ok := file.ReadString(ppfmt, tokenFile)
+	if !ok {
+		return "", false
+	}
+
+	if token == "" {
+		ppfmt.Noticef(pp.EmojiUserError, "The file specified by %s does not contain an API token", key)
+		return "", false
+	}
+
+	return token, true
+}
+
+func readAuthTokenFiles(ppfmt pp.PP) (string, string, bool) {
+	token1, ok := readAuthTokenFile(ppfmt, TokenFileKey1)
+	if !ok {
+		return "", "", false
+	}
+
+	token2, ok := readAuthTokenFile(ppfmt, TokenFileKey2)
+	if !ok {
+		return "", "", false
+	}
+
+	switch {
+	case token1 != "" && token2 != "" && token1 != token2:
+		ppfmt.Noticef(pp.EmojiUserError,
+			"The files specified by %s and %s have conflicting tokens; their content must match", TokenFileKey1, TokenFileKey2)
+		return "", "", false
+	case token1 != "":
+		return token1, TokenFileKey1, true
+	case token2 != "":
+		ppfmt.Hintf(pp.HintAuthTokenNewPrefix, HintAuthTokenNewPrefix)
+		return token2, TokenFileKey2, true
+	default:
+		return "", "", true
+	}
+}
+
+func readAuthToken(ppfmt pp.PP) (string, bool) {
+	tokenPlain, tokenPlainKey, ok := readPlainAuthTokens(ppfmt)
+	if !ok {
+		return "", false
+	}
+
+	tokenFile, tokenFileKey, ok := readAuthTokenFiles(ppfmt)
+	if !ok {
 		return "", false
 	}
 
+	var token string
 	switch {
-	case token != "" && tokenFile != "":
-		ppfmt.Noticef(pp.EmojiUserError, "Cannot have both CF_API_TOKEN and CF_API_TOKEN_FILE set")
+	case tokenPlain != "" && tokenFile != "" && tokenPlain != tokenFile:
+		ppfmt.Noticef(pp.EmojiUserError,
+			"The value of %s does not match the token found in the file specified by %s; they must specify the same token",
+			tokenPlainKey, tokenFileKey)
 		return "", false
-	case token != "":
+	case tokenPlain != "":
+		token = tokenPlain
 	case tokenFile != "":
-		token, ok = file.ReadString(ppfmt, tokenFile)
-		if !ok {
-			return "", false
-		}
-
-		if token == "" {
-			ppfmt.Noticef(pp.EmojiUserError, "The token in the file specified by CF_API_TOKEN_FILE is empty")
-			return "", false
-		}
+		token = tokenFile
 	default:
-		ppfmt.Noticef(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE")
+		ppfmt.Noticef(pp.EmojiUserError, "Needs either %s or %s", TokenKey1, TokenFileKey1)
 		return "", false
 	}
 
 	if !oauthBearerRegex.MatchString(token) {
-		ppfmt.Noticef(pp.EmojiUserWarning, "The API token does not look like a valid OAuth2 bearer token")
+		ppfmt.Noticef(pp.EmojiUserWarning,
+			"The API token appears to be invalid; it does not follow the OAuth2 bearer token format")
 	}
 
 	return token, true
 }
 
-// ReadAuth reads environment variables CF_API_TOKEN, CF_API_TOKEN_FILE, and CF_ACCOUNT_ID
-// and creates an [api.CloudflareAuth].
+// ReadAuth reads environment variables CLOUDFLARE_API_TOKEN, CLOUDFLARE_API_TOKEN_FILE,
+// CF_API_TOKEN, CF_API_TOKEN_FILE, and CF_ACCOUNT_ID and creates an [api.CloudflareAuth].
 func ReadAuth(ppfmt pp.PP, field *api.Auth) bool {
 	token, ok := readAuthToken(ppfmt)
 	if !ok {
diff --git a/internal/pp/hint.go b/internal/pp/hint.go
index bd533b3..cf9368c 100644
--- a/internal/pp/hint.go
+++ b/internal/pp/hint.go
@@ -5,18 +5,19 @@ type Hint int
 
 // All the registered hints.
 const (
-	HintUpdateDockerTemplate Hint = iota
-	HintIP4DetectionFails
-	HintIP6DetectionFails
-	HintIP4MappedIP6Address
-	HintDetectionTimeouts
-	HintUpdateTimeouts
-	HintRecordPermission
-	HintWAFListPermission
-	HintMismatchedRecordAttributes
-	HintMismatchedWAFListAttributes
-	Hint1111Blockage
-	HintExperimentalShoutrrr           // introduced in 1.12.0
-	HintExperimentalWAF                // introduced in 1.14.0
-	HintExperimentalLocalWithInterface // introduced in 1.15.0
+	HintUpdateDockerTemplate           Hint = iota // PUID or PGID was used
+	HintAuthTokenNewPrefix                         // "CF_*" to "CLOUDFLARE_*"
+	HintIP4DetectionFails                          // How to turn off IPv4
+	HintIP6DetectionFails                          // How to set up IPv6 or turn it off
+	HintIP4MappedIP6Address                        // IPv4-mapped IPv6 addresses are bad for AAAA records
+	HintDetectionTimeouts                          // Longer detection timeout
+	HintUpdateTimeouts                             // Longer update timeout
+	HintRecordPermission                           // Permissions to update DNS tokens
+	HintWAFListPermission                          // Permissions to update WAF lists
+	HintMismatchedRecordAttributes                 // Attributes of DNS records have been changed
+	HintMismatchedWAFListAttributes                // Attributes of WAF lists have been changed
+	Hint1111Blockage                               // 1.1.1.1 is blocked
+	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
 )


Test patch:
diff --git a/internal/config/config_read_test.go b/internal/config/config_read_test.go
index 8ee6e44..45d71e2 100644
--- a/internal/config/config_read_test.go
+++ b/internal/config/config_read_test.go
@@ -19,6 +19,7 @@ import (
 func unsetAll(t *testing.T) {
 	t.Helper()
 	unset(t,
+		"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN_FILE",
 		"CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID",
 		"IP4_PROVIDER", "IP6_PROVIDER",
 		"DOMAINS", "IP4_DOMAINS", "IP6_DOMAINS", "WAF_LISTS",
@@ -43,7 +44,7 @@ func TestReadEnvWithOnlyToken(t *testing.T) {
 	mockCtrl := gomock.NewController(t)
 
 	unsetAll(t)
-	store(t, "CF_API_TOKEN", "deadbeaf")
+	store(t, "CLOUDFLARE_API_TOKEN", "deadbeaf")
 
 	var cfg config.Config
 	mockPP := mocks.NewMockPP(mockCtrl)
@@ -79,7 +80,8 @@ func TestReadEnvEmpty(t *testing.T) {
 		mockPP.EXPECT().IsShowing(pp.Info).Return(true),
 		mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Reading settings . . ."),
 		mockPP.EXPECT().Indent().Return(innerMockPP),
-		innerMockPP.EXPECT().Noticef(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE"),
+		innerMockPP.EXPECT().Noticef(pp.EmojiUserError,
+			"Needs either %s or %s", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN_FILE"),
 	)
 	ok := cfg.ReadEnv(mockPP)
 	require.False(t, ok)
diff --git a/internal/config/env_auth_test.go b/internal/config/env_auth_test.go
index 5bc357f..a469cd2 100644
--- a/internal/config/env_auth_test.go
+++ b/internal/config/env_auth_test.go
@@ -15,103 +15,161 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-//nolint:paralleltest // environment vars are global
-func TestReadAuth(t *testing.T) {
-	unset(t, "CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID")
+func useMemFS(memfs fstest.MapFS) {
+	file.FS = memfs
+}
 
+//nolint:paralleltest // environment vars and file system are global
+func TestReadAuth(t *testing.T) {
 	for name, tc := range map[string]struct {
-		token         string
-		account       string
-		ok            bool
-		prepareMockPP func(*mocks.MockPP)
+		mapFS          map[string]string
+		token1         string
+		token2         string
+		fileToken1Path string
+		fileToken2Path string
+		account        string
+		ok             bool
+		expected       string
+		prepareMockPP  func(*mocks.MockPP)
 	}{
-		"success": {"123456789", "", true, nil},
-		"empty-token": {
-			"", "account", false,
+		"success": {
+			map[string]string{"token.txt": "hello"},
+			"123456789", "", "", "", "",
+			true, "123456789", nil,
+		},
+		"empty": {
+			map[string]string{},
+			"", "", "", "", "",
+			false, "",
 			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE")
+				m.EXPECT().Noticef(pp.EmojiUserError,
+					"Needs either %s or %s", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN_FILE")
 			},
 		},
+		"conflicting": {
+			map[string]string{},
+			"token1", "token2", "", "", "",
+			false, "",
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiUserError,
+					"The values of %s and %s do not match; they must specify the same token",
+					"CLOUDFLARE_API_TOKEN", "CF_API_TOKEN")
+			},
+		},
+		"old": {
+			map[string]string{},
+			"", "token2", "", "", "",
+			true, "token2",
+			func(m *mocks.MockPP) {
+				m.EXPECT().Hintf(pp.HintAuthTokenNewPrefix, config.HintAuthTokenNewPrefix)
+			},
+		},
+		"old/same": {
+			map[string]string{},
+			"token", "token", "", "", "",
+			true, "token", nil,
+		},
 		"invalid": {
-			"!!!", "", true,
+			map[string]string{},
+			"!!!", "", "", "", "",
+			true, "!!!",
 			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiUserWarning, "The API token does not look like a valid OAuth2 bearer token")
+				m.EXPECT().Noticef(pp.EmojiUserWarning,
+					"The API token appears to be invalid; it does not follow the OAuth2 bearer token format")
 			},
 		},
 		"account": {
-			"123456789", "secret account", true,
+			map[string]string{},
+			"123456789", "", "", "", "secret account",
+			true, "123456789",
 			func(m *mocks.MockPP) {
 				m.EXPECT().Noticef(pp.EmojiUserWarning, "CF_ACCOUNT_ID is ignored since 1.14.0")
 			},
 		},
 		"copycat": {
-			"YOUR-CLOUDFLARE-API-TOKEN", "", false,
+			map[string]string{},
+			"YOUR-CLOUDFLARE-API-TOKEN", "", "", "", "",
+			false, "",
 			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiUserError, "You need to provide a real API token as CF_API_TOKEN")
+				m.EXPECT().Noticef(pp.EmojiUserError,
+					"You need to provide a real API token as %s", "CLOUDFLARE_API_TOKEN")
 			},
 		},
-	} {
-		t.Run(name, func(t *testing.T) {
-			mockCtrl := gomock.NewController(t)
-
-			store(t, "CF_API_TOKEN", tc.token)
-			store(t, "CF_ACCOUNT_ID", tc.account)
-
-			mockPP := mocks.NewMockPP(mockCtrl)
-			if tc.prepareMockPP != nil {
-				tc.prepareMockPP(mockPP)
-			}
-
-			var field api.Auth
-			ok := config.ReadAuth(mockPP, &field)
-			require.Equal(t, tc.ok, ok)
-			if tc.ok {
-				require.Equal(t, &api.CloudflareAuth{Token: tc.token, BaseURL: ""}, field)
-			} else {
-				require.Nil(t, field)
-			}
-		})
-	}
-}
-
-func useMemFS(memfs fstest.MapFS) {
-	file.FS = memfs
-}
-
-//nolint:paralleltest // environment vars and file system are global
-func TestReadAuthWithFile(t *testing.T) {
-	unset(t, "CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID")
-
-	for name, tc := range map[string]struct {
-		token         string
-		tokenFile     string
-		actualPath    string
-		actualContent string
-		expected      string
-		ok            bool
-		prepareMockPP func(*mocks.MockPP)
-	}{
-		"ok": {"", "test.txt", "test.txt", "hello", "hello", true, nil},
-		"both": {
-			"123456789", "test.txt", "test.txt", "hello", "", false,
+		"file/success": {
+			map[string]string{"token.txt": "hello"},
+			"", "", "token.txt", "", "",
+			true, "hello", nil,
+		},
+		"file/empty": {
+			map[string]string{"empty.txt": ""},
+			"", "", "empty.txt", "", "",
+			false, "",
 			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiUserError, "Cannot have both CF_API_TOKEN and CF_API_TOKEN_FILE set")
+				m.EXPECT().Noticef(pp.EmojiUserError,
+					"The file specified by %s does not contain an API token",
+					"CLOUDFLARE_API_TOKEN_FILE")
 			},
 		},
-		"wrong.path": {
-			"", "wrong.txt", "actual.txt", "hello", "", false,
+		"file/conflicting": {
+			map[string]string{"token1.txt": "hello1", "token2.txt": "hello2"},
+			"", "", "token1.txt", "token2.txt", "",
+			false, "",
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiUserError,
+					"The files specified by %s and %s have conflicting tokens; their content must match",
+					"CLOUDFLARE_API_TOKEN_FILE", "CF_API_TOKEN_FILE")
+			},
+		},
+		"file/conflicting/non-file": {
+			map[string]string{"token.txt": "file"},
+			"plain", "", "token.txt", "", "",
+			false, "",
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiUserError,
+					"The value of %s does not match the token found in the file specified by %s; "+
+						"they must specify the same token",
+					"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN_FILE",
+				)
+			},
+		},
+		"file/same/non-file": {
+			map[string]string{"token.txt": "token"},
+			"token", "", "token.txt", "", "",
+			true, "token", nil,
+		},
+		"file/old": {
+			map[string]string{"token.txt": "hello"},
+			"", "", "", "token.txt", "",
+			true, "hello",
+			func(m *mocks.MockPP) {
+				m.EXPECT().Hintf(pp.HintAuthTokenNewPrefix, config.HintAuthTokenNewPrefix)
+			},
+		},
+		"file/old/same": {
+			map[string]string{"token1.txt": "hello", "token2.txt": "hello"},
+			"", "", "token1.txt", "token2.txt", "",
+			true, "hello", nil,
+		},
+		"file/wrong.path": {
+			map[string]string{},
+			"", "", "wrong.txt", "", "",
+			false, "",
 			func(m *mocks.MockPP) {
 				m.EXPECT().Noticef(pp.EmojiUserError, "Failed to read %q: %v", "wrong.txt", gomock.Any())
 			},
 		},
-		"empty": {
-			"", "test.txt", "test.txt", "", "", false,
+		"file/wrong.path/2": {
+			map[string]string{},
+			"", "", "", "wrong.txt", "",
+			false, "",
 			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiUserError, "The token in the file specified by CF_API_TOKEN_FILE is empty")
+				m.EXPECT().Noticef(pp.EmojiUserError, "Failed to read %q: %v", "wrong.txt", gomock.Any())
 			},
 		},
-		"invalid path": {
-			"", "dir", "dir/test.txt", "hello", "", false,
+		"file/invalid-directory": {
+			map[string]string{"dir/file.txt": ""},
+			"", "", "dir", "", "",
+			false, "",
 			func(m *mocks.MockPP) {
 				m.EXPECT().Noticef(pp.EmojiUserError, "Failed to read %q: %v", "dir", gomock.Any())
 			},
@@ -120,17 +178,22 @@ func TestReadAuthWithFile(t *testing.T) {
 		t.Run(name, func(t *testing.T) {
 			mockCtrl := gomock.NewController(t)
 
-			store(t, "CF_API_TOKEN", tc.token)
-			store(t, "CF_API_TOKEN_FILE", tc.tokenFile)
+			store(t, "CLOUDFLARE_API_TOKEN", tc.token1)
+			store(t, "CLOUDFLARE_API_TOKEN_FILE", tc.fileToken1Path)
+			store(t, "CF_API_TOKEN", tc.token2)
+			store(t, "CF_API_TOKEN_FILE", tc.fileToken2Path)
+			store(t, "CF_ACCOUNT_ID", tc.account)
 
-			useMemFS(fstest.MapFS{
-				tc.actualPath: &fstest.MapFile{
-					Data:    []byte(tc.actualContent),
+			mapFS := fstest.MapFS{}
+			for path, content := range tc.mapFS {
+				mapFS[path] = &fstest.MapFile{
+					Data:    []byte(content),
 					Mode:    0o644,
 					ModTime: time.Unix(1234, 5678),
 					Sys:     nil,
-				},
-			})
+				}
+			}
+			useMemFS(mapFS)
 
 			var field api.Auth
 			mockPP := mocks.NewMockPP(mockCtrl)