291 lines
12 KiB
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")
|
|
}
|
|
}
|