Ctrl K
ComunidadGuías…

config/apps_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package config

import (
	"fmt"
	"os"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// stubPEMB64 is the base64 encoding of a tiny PEM-shaped byte string used
// throughout the tests to satisfy validatePEMKeyB64. Real key content is
// not required, the validator only checks for the BEGIN marker.
const stubPEMB64 = "LS0tLS1CRUdJTiBURVNUIEtFWS0tLS0tCnRlc3RkYXRhCi0tLS0tRU5EIFRFU1QgS0VZLS0tLS0K"

// resetAppsEnv unsets every env var that LoadAppsFromFlatEnv can read so each test
// starts from a known-empty environment. Central list, when a new env var
// is added to the loader, add it here too or the test suite will start
// interfering across runs.
func resetAppsEnv(t *testing.T) {
	t.Helper()
	vars := []string{
		"EXPO_APP_ID",
		"SKIP_LEGACY_APP_ID_FALLBACK",
		"EXPO_ACCESS_TOKEN",
		"KEYS_STORAGE_TYPE",
		"PUBLIC_LOCAL_EXPO_KEY_PATH",
		"PRIVATE_LOCAL_EXPO_KEY_PATH",
		"AWSSM_EXPO_PUBLIC_KEY_SECRET_ID",
		"AWSSM_EXPO_PRIVATE_KEY_SECRET_ID",
		"PUBLIC_EXPO_KEY_B64",
		"PRIVATE_EXPO_KEY_B64",
	}
	for _, v := range vars {
		os.Unsetenv(v)
	}
	ResetAppsForTest()
	t.Cleanup(func() {
		for _, v := range vars {
			os.Unsetenv(v)
		}
		ResetAppsForTest()
	})
}

// -----------------------------------------------------------------------------
// validateApp / ValidateKeys, unit tests on the validator only. No env.
// -----------------------------------------------------------------------------

func TestValidateApp_AcceptsEachMode(t *testing.T) {
	cases := map[string]AppConfig{
		"local": {
			Id:          "app-1",
			AccessToken: "token",
			Keys: KeysConfig{
				Mode:        KeysModeLocal,
				PublicPath:  "/keys/pub.pem",
				PrivatePath: "/keys/priv.pem",
			},
		},
		"aws-secrets-manager": {
			Id:          "app-1",
			AccessToken: "token",
			Keys: KeysConfig{
				Mode:            KeysModeAWSSM,
				PublicSecretId:  "/eoota/pub",
				PrivateSecretId: "/eoota/priv",
			},
		},
		"environment": {
			Id:          "app-1",
			AccessToken: "token",
			Keys: KeysConfig{
				Mode:       KeysModeEnvironment,
				PublicB64:  stubPEMB64,
				PrivateB64: stubPEMB64,
			},
		},
	}
	for name, app := range cases {
		t.Run(name, func(t *testing.T) {
			assert.NoError(t, validateApp(&app, 0))
		})
	}
}

func TestValidateApp_RejectsBadId(t *testing.T) {
	cases := map[string]string{
		"empty":           "",
		"slash":           "foo/bar",
		"backslash":       "foo\\bar",
		"space":           "foo bar",
		"tab":             "foo\tbar",
		"newline":         "foo\nbar",
		"carriage-return": "foo\rbar",
		"null-byte":       "foo\x00bar",
		"control-char":    "foo\x01bar",
		"dot":             ".",
		"dotdot":          "..",
		"unicode-letter":  "app-é",
		"unicode-cjk":     "app中",
		"unicode-slash":   "app∕bar", // U+2215 division slash, a `/` lookalike
		"fullwidth-slash": "app/bar", // U+FF0F
		"colon":           "app:1",
		"plus":            "app+1",
		"at":              "app@1",
		"emoji":           "app🚀",
	}
	for name, badId := range cases {
		t.Run(name, func(t *testing.T) {
			app := AppConfig{
				Id:          badId,
				AccessToken: "token",
				Keys: KeysConfig{
					Mode:        KeysModeLocal,
					PublicPath:  "/p",
					PrivatePath: "/q",
				},
			}
			err := validateApp(&app, 0)
			require.Error(t, err)
			assert.Contains(t, err.Error(), "apps[0].id")
		})
	}
}

func TestValidateApp_RejectsReservedIds(t *testing.T) {
	// Each of these collides with a top-level static route registered in
	// router.go. Gorilla mux resolves static routes before patterns so an
	// app id matching one of these would silently never receive traffic.
	reserved := []string{"api", "assets", "auth", "dashboard", "hc", "manifest", "metrics"}
	for _, id := range reserved {
		t.Run(id, func(t *testing.T) {
			app := AppConfig{
				Id:          id,
				AccessToken: "token",
				Keys: KeysConfig{
					Mode:        KeysModeLocal,
					PublicPath:  "/p",
					PrivatePath: "/q",
				},
			}
			err := validateApp(&app, 0)
			require.Error(t, err)
			assert.Contains(t, err.Error(), "collides with a top-level route")
		})
	}
}

func TestValidateApp_RejectsTooLongId(t *testing.T) {
	app := AppConfig{
		Id:          strings.Repeat("a", maxAppIdLen+1),
		AccessToken: "token",
		Keys: KeysConfig{
			Mode:        KeysModeLocal,
			PublicPath:  "/p",
			PrivatePath: "/q",
		},
	}
	err := validateApp(&app, 0)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "exceeds max length")
}

func TestValidateApp_AcceptsIdAtMaxLength(t *testing.T) {
	app := AppConfig{
		Id:          strings.Repeat("a", maxAppIdLen),
		AccessToken: "token",
		Keys: KeysConfig{
			Mode:        KeysModeLocal,
			PublicPath:  "/p",
			PrivatePath: "/q",
		},
	}
	assert.NoError(t, validateApp(&app, 0))
}

func TestValidateApp_RejectsMissingAccessToken(t *testing.T) {
	app := AppConfig{
		Id:          "app-1",
		AccessToken: "",
		Keys: KeysConfig{
			Mode:        KeysModeLocal,
			PublicPath:  "/p",
			PrivatePath: "/q",
		},
	}
	err := validateApp(&app, 2)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "apps[2].accessToken")
}

func TestValidateKeys_RejectsMissingModeFields(t *testing.T) {
	cases := map[string]KeysConfig{
		"local missing public":        {Mode: KeysModeLocal, PrivatePath: "/q"},
		"local missing private":       {Mode: KeysModeLocal, PublicPath: "/p"},
		"aws-sm missing public":       {Mode: KeysModeAWSSM, PrivateSecretId: "/q"},
		"aws-sm missing private":      {Mode: KeysModeAWSSM, PublicSecretId: "/p"},
		"environment missing public":  {Mode: KeysModeEnvironment, PrivateB64: "xx"},
		"environment missing private": {Mode: KeysModeEnvironment, PublicB64: "xx"},
	}
	for name, k := range cases {
		t.Run(name, func(t *testing.T) {
			err := ValidateKeys(&k, "apps[0].keys")
			require.Error(t, err)
			assert.Contains(t, err.Error(), "requires")
		})
	}
}

func TestValidateKeys_RejectsCrossModeFields(t *testing.T) {
	cases := map[string]KeysConfig{
		"local with aws-sm field": {
			Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q",
			PublicSecretId: "leaked",
		},
		"local with b64 field": {
			Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q",
			PublicB64: "leaked",
		},
		"aws-sm with local field": {
			Mode: KeysModeAWSSM, PublicSecretId: "/p", PrivateSecretId: "/q",
			PublicPath: "leaked",
		},
		"aws-sm with b64 field": {
			Mode: KeysModeAWSSM, PublicSecretId: "/p", PrivateSecretId: "/q",
			PrivateB64: "leaked",
		},
		"environment with local field": {
			Mode: KeysModeEnvironment, PublicB64: "xx", PrivateB64: "yy",
			PublicPath: "leaked",
		},
		"environment with aws-sm field": {
			Mode: KeysModeEnvironment, PublicB64: "xx", PrivateB64: "yy",
			PrivateSecretId: "leaked",
		},
	}
	for name, k := range cases {
		t.Run(name, func(t *testing.T) {
			err := ValidateKeys(&k, "apps[0].keys")
			require.Error(t, err)
			assert.Contains(t, err.Error(), "must not set")
		})
	}
}

func TestValidateKeys_EnvironmentMode_RejectsInvalidBase64(t *testing.T) {
	// Base64 that does not decode (padding mismatch) must fail at boot,
	// not at the first manifest sign.
	k := KeysConfig{
		Mode:       KeysModeEnvironment,
		PublicB64:  "not-base64!!",
		PrivateB64: stubPEMB64,
	}
	err := ValidateKeys(&k, "apps[0].keys")
	require.Error(t, err)
	assert.Contains(t, err.Error(), "invalid base64")
	assert.Contains(t, err.Error(), "publicB64")
}

func TestValidateKeys_EnvironmentMode_RejectsNonPEM(t *testing.T) {
	// Valid base64 that doesn't decode to a PEM-shaped payload, a common
	// mistake when the operator base64-encodes the key contents without
	// the BEGIN/END markers.
	rawB64 := "aGVsbG8td29ybGQ=" // -> "hello-world", no BEGIN marker
	k := KeysConfig{
		Mode:       KeysModeEnvironment,
		PublicB64:  stubPEMB64,
		PrivateB64: rawB64,
	}
	err := ValidateKeys(&k, "apps[0].keys")
	require.Error(t, err)
	assert.Contains(t, err.Error(), "not a PEM key")
	assert.Contains(t, err.Error(), "privateB64")
}

func TestValidateKeys_RejectsMissingOrUnknownMode(t *testing.T) {
	t.Run("missing", func(t *testing.T) {
		err := ValidateKeys(&KeysConfig{}, "apps[0].keys")
		require.Error(t, err)
		assert.Contains(t, err.Error(), "required")
	})
	t.Run("unknown", func(t *testing.T) {
		err := ValidateKeys(&KeysConfig{Mode: "env-b64"}, "apps[0].keys")
		require.Error(t, err)
		assert.Contains(t, err.Error(), "invalid")
		// The "env-b64" value is a common migration mistake, the message
		// must spell out the three valid modes so the user knows to swap.
		assert.Contains(t, err.Error(), "environment")
	})
}

// -----------------------------------------------------------------------------
// LoadAppsFromFlatEnv, flat env. The only stateless config source: one app, v1-compat.
// Each key mode, and failure modes.
// -----------------------------------------------------------------------------

func TestLoadAppsFromFlatEnv_LocalMode(t *testing.T) {
	resetAppsEnv(t)
	os.Setenv("EXPO_APP_ID", "solo")
	os.Setenv("EXPO_ACCESS_TOKEN", "tok")
	os.Setenv("KEYS_STORAGE_TYPE", "local")
	os.Setenv("PUBLIC_LOCAL_EXPO_KEY_PATH", "/k/pub.pem")
	os.Setenv("PRIVATE_LOCAL_EXPO_KEY_PATH", "/k/priv.pem")

	require.NoError(t, LoadAppsFromFlatEnv())
	a, err := GetAppConfig("solo")
	require.NoError(t, err)
	assert.Equal(t, KeysModeLocal, a.Keys.Mode)
	assert.Equal(t, "/k/pub.pem", a.Keys.PublicPath)
	assert.Equal(t, "/k/priv.pem", a.Keys.PrivatePath)
	assert.Equal(t, "tok", a.AccessToken)
}

func TestLoadAppsFromFlatEnv_DefaultsToLocalWhenStorageTypeUnset(t *testing.T) {
	// v1 DefaultEnvValues had KEYS_STORAGE_TYPE=local; in v2 that default
	// moved into parseFlatEnvApp itself. Unsetting the var must behave the
	// same as setting it to "local".
	resetAppsEnv(t)
	os.Setenv("EXPO_APP_ID", "solo")
	os.Setenv("EXPO_ACCESS_TOKEN", "tok")
	os.Setenv("PUBLIC_LOCAL_EXPO_KEY_PATH", "/k/pub.pem")
	os.Setenv("PRIVATE_LOCAL_EXPO_KEY_PATH", "/k/priv.pem")

	require.NoError(t, LoadAppsFromFlatEnv())
	a, err := GetAppConfig("solo")
	require.NoError(t, err)
	assert.Equal(t, KeysModeLocal, a.Keys.Mode)
}

func TestLoadAppsFromFlatEnv_AWSSMMode(t *testing.T) {
	resetAppsEnv(t)
	os.Setenv("EXPO_APP_ID", "solo")
	os.Setenv("EXPO_ACCESS_TOKEN", "tok")
	os.Setenv("KEYS_STORAGE_TYPE", "aws-secrets-manager")
	os.Setenv("AWSSM_EXPO_PUBLIC_KEY_SECRET_ID", "/eoota/pub")
	os.Setenv("AWSSM_EXPO_PRIVATE_KEY_SECRET_ID", "/eoota/priv")

	require.NoError(t, LoadAppsFromFlatEnv())
	a, err := GetAppConfig("solo")
	require.NoError(t, err)
	assert.Equal(t, KeysModeAWSSM, a.Keys.Mode)
	assert.Equal(t, "/eoota/pub", a.Keys.PublicSecretId)
	assert.Equal(t, "/eoota/priv", a.Keys.PrivateSecretId)
}

func TestLoadAppsFromFlatEnv_EnvironmentMode(t *testing.T) {
	resetAppsEnv(t)
	os.Setenv("EXPO_APP_ID", "solo")
	os.Setenv("EXPO_ACCESS_TOKEN", "tok")
	os.Setenv("KEYS_STORAGE_TYPE", "environment")
	os.Setenv("PUBLIC_EXPO_KEY_B64", stubPEMB64)
	os.Setenv("PRIVATE_EXPO_KEY_B64", stubPEMB64)

	require.NoError(t, LoadAppsFromFlatEnv())
	a, err := GetAppConfig("solo")
	require.NoError(t, err)
	assert.Equal(t, KeysModeEnvironment, a.Keys.Mode)
	assert.Equal(t, stubPEMB64, a.Keys.PublicB64)
}

func TestLoadAppsFromFlatEnv_RejectsUnknownStorageType(t *testing.T) {
	// An unknown KEYS_STORAGE_TYPE leaves Mode empty in parseFlatEnvApp,
	// then validateApp catches it with the "mode is required" error.
	resetAppsEnv(t)
	os.Setenv("EXPO_APP_ID", "solo")
	os.Setenv("EXPO_ACCESS_TOKEN", "tok")
	os.Setenv("KEYS_STORAGE_TYPE", "vault") // not supported
	err := LoadAppsFromFlatEnv()
	require.Error(t, err)
	assert.Contains(t, err.Error(), "mode is required")
}

func TestLoadAppsFromFlatEnv_RejectsMissingToken(t *testing.T) {
	resetAppsEnv(t)
	os.Setenv("EXPO_APP_ID", "solo")
	// No EXPO_ACCESS_TOKEN, no keys set either
	err := LoadAppsFromFlatEnv()
	require.Error(t, err)
	assert.Contains(t, err.Error(), "accessToken")
}

// -----------------------------------------------------------------------------
// LoadAppsFromFlatEnv, "nothing set" error path.
// -----------------------------------------------------------------------------

func TestLoadAppsFromFlatEnv_NoSourceSetReturnsActionableError(t *testing.T) {
	resetAppsEnv(t)
	err := LoadAppsFromFlatEnv()
	require.Error(t, err)
	// The error message is part of the UX, it must name the flat-env entry
	// point and point multi-app users at the control plane so a user who
	// forgot to set anything isn't left guessing.
	msg := err.Error()
	assert.Contains(t, msg, "EXPO_APP_ID")
	assert.Contains(t, msg, "control plane")
}

// -----------------------------------------------------------------------------
// GetAppConfig / ListAppIds, lookup API.
// -----------------------------------------------------------------------------

func TestGetAppConfig_UnknownIdReturnsError(t *testing.T) {
	resetAppsEnv(t)
	SetAppsForTest([]AppConfig{
		{Id: "known", AccessToken: "t", Keys: KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"}},
	})

	_, err := GetAppConfig("unknown")
	require.Error(t, err)
	assert.Contains(t, err.Error(), `"unknown"`)
}

func TestListAppIds_EmptyWhenNoConfigLoaded(t *testing.T) {
	resetAppsEnv(t) // also resets the cache
	assert.Empty(t, ListAppIds())
}

func TestListAppIds_ReturnsAllLoaded(t *testing.T) {
	// The registry is single-app in production stateless mode, but it backs
	// the multi-app control-plane store too, seed several apps directly to
	// prove the lookup surface holds more than one.
	resetAppsEnv(t)
	SetAppsForTest([]AppConfig{
		{Id: "a", AccessToken: "t", Keys: KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"}},
		{Id: "b", AccessToken: "t", Keys: KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"}},
		{Id: "c", AccessToken: "t", Keys: KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"}},
	})
	assert.ElementsMatch(t, []string{"a", "b", "c"}, ListAppIds())
}

// -----------------------------------------------------------------------------
// Concurrency, GetAppConfig / ListAppIds must be safe for concurrent reads
// (the server calls them from every handler goroutine).
// -----------------------------------------------------------------------------

func TestLookupIsConcurrencySafe(t *testing.T) {
	resetAppsEnv(t)
	// Build a decent-sized config so the map iteration in ListAppIds has
	// something to do under contention.
	var apps []AppConfig
	for i := 0; i < 50; i++ {
		apps = append(apps, AppConfig{
			Id:          fmt.Sprintf("app-%d", i),
			AccessToken: "t",
			Keys:        KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"},
		})
	}
	SetAppsForTest(apps)

	const readers = 16
	const iters = 500
	done := make(chan struct{}, readers)
	for r := 0; r < readers; r++ {
		go func() {
			for i := 0; i < iters; i++ {
				_, _ = GetAppConfig(fmt.Sprintf("app-%d", i%50))
				_ = ListAppIds()
			}
			done <- struct{}{}
		}()
	}
	for r := 0; r < readers; r++ {
		<-done
	}
	// Test passes if no race was detected (go test -race) and no panic.
}

// -----------------------------------------------------------------------------
// ResetAppsForTest, must actually reset so test isolation holds. If this
// regresses, every other test in the package becomes order-dependent.
// -----------------------------------------------------------------------------

// -----------------------------------------------------------------------------
// Optional Name field, display label used by the dashboard. Absence must be
// accepted, presence must round-trip, and ListApps must surface it.
// -----------------------------------------------------------------------------

func TestGetAppConfig_PreservesOptionalName(t *testing.T) {
	resetAppsEnv(t)
	SetAppsForTest([]AppConfig{
		{Id: "no-name", AccessToken: "t", Keys: KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"}},
		{Id: "with-name", Name: "Production", AccessToken: "t", Keys: KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"}},
	})

	noName, err := GetAppConfig("no-name")
	require.NoError(t, err)
	assert.Empty(t, noName.Name)

	withName, err := GetAppConfig("with-name")
	require.NoError(t, err)
	assert.Equal(t, "Production", withName.Name)
}

func TestListApps_ReturnsDescriptorsWithName(t *testing.T) {
	resetAppsEnv(t)
	SetAppsForTest([]AppConfig{
		{Id: "a", Name: "App A", AccessToken: "t", Keys: KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"}},
		{Id: "b", AccessToken: "t", Keys: KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"}},
	})

	// Descriptors are returned in unspecified order; reshape into a map so
	// the assertion is stable.
	byId := map[string]AppDescriptor{}
	for _, d := range ListApps() {
		byId[d.Id] = d
	}
	assert.Equal(t, "App A", byId["a"].Name)
	assert.Equal(t, "", byId["b"].Name)
	assert.Len(t, byId, 2)
}

func TestListApps_EmptyWhenNoConfigLoaded(t *testing.T) {
	resetAppsEnv(t)
	assert.Empty(t, ListApps())
}

func TestResetAppsForTest_ClearsRegistry(t *testing.T) {
	resetAppsEnv(t)
	SetAppsForTest([]AppConfig{
		{Id: "x", AccessToken: "t", Keys: KeysConfig{Mode: KeysModeLocal, PublicPath: "/p", PrivatePath: "/q"}},
	})
	assert.NotEmpty(t, ListAppIds())

	ResetAppsForTest()
	assert.Empty(t, ListAppIds())
	_, err := GetAppConfig("x")
	assert.Error(t, err)
}

// -----------------------------------------------------------------------------
// LegacyFallbackAppId, which app a manifest/asset request with no expo-app-id
// header belongs to. Drives whether v1 clients keep receiving updates after the
// v2 upgrade, so each branch is pinned explicitly.
// -----------------------------------------------------------------------------

func TestLegacyFallbackAppId_ReturnsExpoAppId(t *testing.T) {
	resetAppsEnv(t)
	os.Setenv("EXPO_APP_ID", "d8471dfc-c3e9-4e14-afd9-21dc34cc498a")

	assert.Equal(t, "d8471dfc-c3e9-4e14-afd9-21dc34cc498a", LegacyFallbackAppId())
}

func TestLegacyFallbackAppId_EmptyWithoutExpoAppId(t *testing.T) {
	// The control-plane shape: apps come from the dashboard, EXPO_APP_ID is
	// unset, no v1 client can exist. Nothing to fall back to, so the header
	// stays mandatory and the handler rejects the request.
	resetAppsEnv(t)

	assert.Empty(t, LegacyFallbackAppId())
}

func TestLegacyFallbackAppId_SkipFlagDisablesFallback(t *testing.T) {
	// strconv.ParseBool accepts 1/t/T/TRUE/true/True, every truthy spelling
	// must disable the fallback, so an operator who opted out gets the opt-out
	// no matter how they spelled it.
	for _, v := range []string{"true", "1", "True", "TRUE", "t"} {
		t.Run("skip="+v, func(t *testing.T) {
			resetAppsEnv(t)
			os.Setenv("EXPO_APP_ID", "d8471dfc-c3e9-4e14-afd9-21dc34cc498a")
			os.Setenv("SKIP_LEGACY_APP_ID_FALLBACK", v)

			assert.Empty(t, LegacyFallbackAppId())
		})
	}
}

func TestLegacyFallbackAppId_FalseyOrUnparseableSkipKeepsFallback(t *testing.T) {
	// Anything that parses false, or does not parse at all, must leave the
	// fallback on. Failing open matters here: guessing that "yes" means true
	// would silently kill the update channel of every v1 client on the deploy.
	for _, v := range []string{"false", "0", "", "yes", "nope"} {
		t.Run("skip="+v, func(t *testing.T) {
			resetAppsEnv(t)
			os.Setenv("EXPO_APP_ID", "d8471dfc-c3e9-4e14-afd9-21dc34cc498a")
			os.Setenv("SKIP_LEGACY_APP_ID_FALLBACK", v)

			assert.Equal(t, "d8471dfc-c3e9-4e14-afd9-21dc34cc498a", LegacyFallbackAppId())
		})
	}
}

func TestLegacyFallbackAppId_TrimsWhitespace(t *testing.T) {
	// ReadAppsFromFlatEnv trims EXPO_APP_ID before registering the app, so a
	// padded value must resolve to the same id here or the fallback would look
	// up an app that is not in the registry.
	resetAppsEnv(t)
	os.Setenv("EXPO_APP_ID", "  d8471dfc-c3e9-4e14-afd9-21dc34cc498a\n")

	assert.Equal(t, "d8471dfc-c3e9-4e14-afd9-21dc34cc498a", LegacyFallbackAppId())
}