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") } }