maidn-cli/cmd/cloudflare_tunnel_test.go

291 lines
12 KiB
Go

package cmd
import (
"context"
"errors"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/cloudflare"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
)
type fakeCloudflareRouteAPI struct {
ensured bool
deleted bool
ensureErr error
deleteCalls int
}
func (f *fakeCloudflareRouteAPI) EnsureCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
return &routeTestError{}
}
f.ensured = true
return f.ensureErr
}
func (f *fakeCloudflareRouteAPI) DeleteCNAME(_ context.Context, zoneID, hostname, tunnelID string) error {
if zoneID != "zone-id" || hostname != "app.example.test" || tunnelID != "tunnel-id" {
return &routeTestError{}
}
f.deleted = true
f.deleteCalls++
return nil
}
type routeTestError struct{}
func (*routeTestError) Error() string { return "unexpected route request" }
func TestCloudflareTunnelRouteCommandsUpdateEncryptedStateAndDNS(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalWrite := writeCloudflareTunnelSecrets
originalClient := newCloudflareRouteClient
originalInitialize := initializeCloudflareTunnelOpenBao
originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
writeCloudflareTunnelSecrets = originalWrite
newCloudflareRouteClient = originalClient
initializeCloudflareTunnelOpenBao = originalInitialize
cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID
})
values, err := (cloudflare.StoredTunnel{
Credentials: cloudflare.Credentials{AccountTag: "account-id", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel-id"},
Config: cloudflare.NewConfig("tunnel-id"),
}).Values()
if err != nil {
t.Fatal(err)
}
state := map[string]map[string]string{
"platform/cloudflare": {"api-token": "test-api-token"},
"platform/cloudflare-tunnel": values,
}
client := &fakeCloudflareRouteAPI{}
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
}
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
writes := 0
writeCloudflareTunnelSecrets = func(_ string, _ string, secrets map[string]map[string]string) error {
if writes == 0 && !client.ensured {
t.Fatal("route add saved config before ensuring the CNAME")
}
if writes == 1 && client.deleted {
t.Fatal("route remove deleted the CNAME before saving config")
}
state = secrets
writes++
return nil
}
newCloudflareRouteClient = func(token string) cloudflare.API {
if token != "test-api-token" {
t.Fatal("Cloudflare route command leaked the token beyond the API boundary")
}
return client
}
seeds := 0
var seedErr error
initializeCloudflareTunnelOpenBao = func(config.Config) error {
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
wantIngress := 2
if seeds%2 == 1 {
wantIngress = 1
}
if err != nil || !present || len(stored.Config.Ingress) != wantIngress {
t.Fatal("OpenBao was not seeded with the saved tunnel config")
}
seeds++
return seedErr
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelHostname = "app.example.test"
cloudflareTunnelService = "http://service.default.svc:8080"
cloudflareTunnelZoneID = "zone-id"
if err := runCloudflareTunnelRoute(nil, true); err != nil {
t.Fatal(err)
}
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 2 || !client.ensured || seeds != 1 {
t.Fatal("route add did not save config and ensure the CNAME")
}
if err := runCloudflareTunnelRoute(nil, false); err != nil {
t.Fatal(err)
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 1 || !client.deleted || seeds != 2 {
t.Fatal("route remove did not save config and delete the CNAME")
}
client.ensureErr = errors.New("unavailable")
if err := runCloudflareTunnelRoute(nil, true); err == nil {
t.Fatal("route add accepted a CNAME failure")
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 2 || seeds != 2 {
t.Fatal("route add saved ingress config after a CNAME failure")
}
client.ensureErr = nil
seedErr = errors.New("unavailable")
if err := runCloudflareTunnelRoute(nil, true); err == nil || strings.Contains(err.Error(), "unavailable") {
t.Fatal("route add did not return a safe OpenBao seeding error")
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 2 || writes != 3 || seeds != 3 {
t.Fatal("route add did not retain DNS-safe state before OpenBao seeding failed")
}
deletes := client.deleteCalls
if err := runCloudflareTunnelRoute(nil, false); err == nil || strings.Contains(err.Error(), "unavailable") {
t.Fatal("route remove did not return a safe OpenBao seeding error")
}
stored, present, err = cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if err != nil || !present || len(stored.Config.Ingress) != 1 || writes != 4 || seeds != 4 || client.deleteCalls != deletes {
t.Fatal("route remove deleted DNS before OpenBao seeding completed")
}
}
func TestCloudflareTunnelRouteErrorsDoNotRevealToken(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalPath, originalHostname, originalService, originalZoneID := cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
cloudflareTunnelConfigPath, cloudflareTunnelHostname, cloudflareTunnelService, cloudflareTunnelZoneID = originalPath, originalHostname, originalService, originalZoneID
})
loadCloudflareTunnelConfig = func(string) (config.Config, error) { return config.Config{}, nil }
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) {
return map[string]map[string]string{"platform/cloudflare": {"api-token": "test-api-token"}}, nil
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelHostname = "app.example.test"
cloudflareTunnelService = "http://service.default.svc:8080"
cloudflareTunnelZoneID = "zone-id"
err := runCloudflareTunnelRoute(nil, true)
if err == nil || strings.Contains(err.Error(), "test-api-token") {
t.Fatal("route command error revealed the Cloudflare token")
}
}
func TestCloudflareTunnelImportAcceptsAbsentOrLegacyState(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalWrite := writeCloudflareTunnelSecrets
originalInitialize := initializeCloudflareTunnelOpenBao
originalCredentials := readCloudflareTunnelCredentials
originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
writeCloudflareTunnelSecrets = originalWrite
initializeCloudflareTunnelOpenBao = originalInitialize
readCloudflareTunnelCredentials = originalCredentials
cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile
})
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelCredentialsFile = "local-credentials.json"
for _, state := range []map[string]map[string]string{
{},
{"platform/cloudflare-tunnel": {"token": "legacy-run-token"}},
} {
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
readCloudflareTunnelCredentials = func(path string) (cloudflare.Credentials, error) {
if path != cloudflareTunnelCredentialsFile {
t.Fatal("credentials were not read from the explicit file path")
}
return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil
}
written := false
writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error {
state = values
written = true
return nil
}
initializeCloudflareTunnelOpenBao = func(config.Config) error {
stored, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"])
if !written || err != nil || !present || len(stored.Config.Ingress) != 1 {
t.Fatal("OpenBao was not seeded after encrypted tunnel state was saved")
}
return nil
}
if err := runCloudflareTunnelImport(); err != nil {
t.Fatal(err)
}
if _, present, err := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); err != nil || !present || len(state["platform/cloudflare-tunnel"]) != 2 {
t.Fatal("credentials file was not imported as managed tunnel state")
}
}
}
func TestCloudflareTunnelImportRefusesManagedStateAndReturnsSafeSeedFailure(t *testing.T) {
originalLoad := loadCloudflareTunnelConfig
originalRead := readCloudflareTunnelSecrets
originalWrite := writeCloudflareTunnelSecrets
originalInitialize := initializeCloudflareTunnelOpenBao
originalCredentials := readCloudflareTunnelCredentials
originalPath, originalCredentialsFile := cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile
t.Cleanup(func() {
loadCloudflareTunnelConfig = originalLoad
readCloudflareTunnelSecrets = originalRead
writeCloudflareTunnelSecrets = originalWrite
initializeCloudflareTunnelOpenBao = originalInitialize
readCloudflareTunnelCredentials = originalCredentials
cloudflareTunnelConfigPath, cloudflareTunnelCredentialsFile = originalPath, originalCredentialsFile
})
loadCloudflareTunnelConfig = func(string) (config.Config, error) {
return config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets", AgeKeyPath: "age"}}, nil
}
cloudflareTunnelConfigPath = "bootstrap.yaml"
cloudflareTunnelCredentialsFile = "local-credentials.json"
managed, err := (cloudflare.StoredTunnel{Credentials: cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, Config: cloudflare.NewConfig("tunnel")}).Values()
if err != nil {
t.Fatal(err)
}
state := map[string]map[string]string{"platform/cloudflare-tunnel": managed}
readCloudflareTunnelSecrets = func(string, string) (map[string]map[string]string, error) { return state, nil }
readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) {
t.Fatal("managed state import read the credential file")
return cloudflare.Credentials{}, nil
}
writeCloudflareTunnelSecrets = func(string, string, map[string]map[string]string) error {
t.Fatal("managed state import overwrote encrypted state")
return nil
}
initializeCloudflareTunnelOpenBao = func(config.Config) error {
t.Fatal("managed state import seeded OpenBao")
return nil
}
if err := runCloudflareTunnelImport(); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
t.Fatal("managed state import was not refused")
}
state = map[string]map[string]string{}
readCloudflareTunnelCredentials = func(string) (cloudflare.Credentials, error) {
return cloudflare.Credentials{AccountTag: "account", TunnelSecret: "test-tunnel-secret", TunnelID: "tunnel"}, nil
}
written := false
writeCloudflareTunnelSecrets = func(_, _ string, values map[string]map[string]string) error {
written = true
state = values
return nil
}
initializeCloudflareTunnelOpenBao = func(config.Config) error {
if !written {
t.Fatal("OpenBao seeding ran before encrypted state was saved")
}
return errors.New("unavailable")
}
err = runCloudflareTunnelImport()
if err == nil || strings.Contains(err.Error(), "unavailable") {
t.Fatal("import did not return a safe OpenBao seeding error")
}
if _, present, parseErr := cloudflare.ParseStoredTunnel(state["platform/cloudflare-tunnel"]); parseErr != nil || !present {
t.Fatal("import did not retain encrypted state after OpenBao seeding failed")
}
}