From eccd2e2c6f4e49e574e49a23fde6d35cf74c1dbc Mon Sep 17 00:00:00 2001 From: Evan Goode Date: Sun, 9 Aug 2026 15:56:55 -0400 Subject: [PATCH] Cleanup, FallbackAPIServer tests --- account_test.go | 2 +- authlib_injector_test.go | 11 +- common.go | 4 +- config.go | 8 +- config_test.go | 316 ++++++++++++++++++++++++------------ discovery_test.go | 2 +- fallback_api_server_test.go | 172 ++++++++++++++++++++ front_test.go | 7 +- services_test.go | 12 +- test_suite_test.go | 75 ++++++--- 10 files changed, 455 insertions(+), 154 deletions(-) create mode 100644 fallback_api_server_test.go diff --git a/account_test.go b/account_test.go index 590fc79..566e66f 100644 --- a/account_test.go +++ b/account_test.go @@ -30,7 +30,7 @@ func TestAccount(t *testing.T) { ts.SetupAux(auxConfig) config := testConfig() - config.FallbackAPIServers = []FallbackAPIServerConfig{ts.ToFallbackAPIServer(ts.AuxApp, "Aux")} + config.FallbackAPIServers = []FallbackAPIServerConfig{ts.ToFallbackAPIServerAuthlibInjector(ts.AuxApp, "Aux")} ts.Setup(config) defer ts.Teardown() diff --git a/authlib_injector_test.go b/authlib_injector_test.go index 683b0d2..7668c85 100644 --- a/authlib_injector_test.go +++ b/authlib_injector_test.go @@ -10,9 +10,6 @@ import ( "testing" ) -const FALLBACK_SKIN_DOMAIN_A = "a.example.com" -const FALLBACK_SKIN_DOMAIN_B = "b.example.com" - func TestAuthlibInjector(t *testing.T) { t.Parallel() // authlib-injector also expects a X-Authlib-Injector-API-Location header @@ -38,9 +35,9 @@ func TestAuthlibInjector(t *testing.T) { ts.SetupAux(auxConfig) config := testConfig() - fallback := ts.ToFallbackAPIServer(ts.AuxApp, "Aux") - fallback.SkinDomains = []string{FALLBACK_SKIN_DOMAIN_A, FALLBACK_SKIN_DOMAIN_B} - config.FallbackAPIServers = []FallbackAPIServerConfig{fallback} + config.FallbackAPIServers = []FallbackAPIServerConfig{ + ts.ToFallbackAPIServerAuthlibInjector(ts.AuxApp, "Aux"), + } ts.Setup(config) defer ts.Teardown() @@ -71,7 +68,7 @@ func (ts *TestSuite) testAuthlibInjectorRootFallback(t *testing.T) { var response authlibInjectorResponse assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response)) - assert.Equal(t, []string{ts.App.Config.Domain, FALLBACK_SKIN_DOMAIN_A, FALLBACK_SKIN_DOMAIN_B}, response.SkinDomains) + assert.ElementsMatch(t, []string{ts.App.Config.Domain, ts.AuxApp.Config.Domain}, response.SkinDomains) assert.True(t, response.Meta.FeatureNonEmailLogin) } diff --git a/common.go b/common.go index fec72cb..55996d3 100644 --- a/common.go +++ b/common.go @@ -1164,8 +1164,8 @@ func NewFallbackAPIServer(config *FallbackAPIServerConfig) (FallbackAPIServer, e legacy := config.URLs.MustArg3() sessionGetProfileByIDURL = legacy.SessionURL + "/session/minecraft/profile" - sessionGetProfileByIDURL = legacy.SessionURL + "/session/minecraft/hasJoined" - profilesGetManyByNameURL = legacy.AccountURL + "/api/profiles/minecraft" + sessionVerifyURL = legacy.SessionURL + "/session/minecraft/hasJoined" + profilesGetManyByNameURL = legacy.AccountURL + "/profiles/minecraft" publicKeysURL := legacy.ServicesURL + "/publickeys" var err error diff --git a/config.go b/config.go index f26132a..b4c84b0 100644 --- a/config.go +++ b/config.go @@ -786,7 +786,7 @@ func CleanConfig(rawConfig *RawConfig) (Config, []Deprecation, error) { if rawFallbackAPIServer.AuthlibInjectorURL != nil { if urls.IsPresent() { - return Config{}, nil, fmt.Errorf("FallbackAPIServer %s: can't supply both legacy URLs and AuthlibInjectorURL") + return Config{}, nil, fmt.Errorf("FallbackAPIServer %s: can't supply both legacy URLs and AuthlibInjectorURL", nickname) } authlibInjectorURL := orElse(rawFallbackAPIServer.AuthlibInjectorURL, defaultFallbackAPIServerAuthlibInjector().AuthlibInjectorURL) @@ -806,11 +806,11 @@ func CleanConfig(rawConfig *RawConfig) (Config, []Deprecation, error) { if rawFallbackAPIServer.DiscoveryMinecraftClientURL != nil { if u, ok := urls.Get(); ok { if u.IsArg3() { - return Config{}, nil, fmt.Errorf("FallbackAPIServer %s: can't supply both legacy URLs and DiscoveryMinecraftClientURL") + return Config{}, nil, fmt.Errorf("FallbackAPIServer %s: can't supply both legacy URLs and DiscoveryMinecraftClientURL", nickname) } else if u.IsArg2() { - return Config{}, nil, fmt.Errorf("FallbackAPIServer %s: can't supply both AuthlibInjectorURL and DiscoveryMinecraftClientURL") + return Config{}, nil, fmt.Errorf("FallbackAPIServer %s: can't supply both AuthlibInjectorURL and DiscoveryMinecraftClientURL", nickname) } else { - return Config{}, nil, fmt.Errorf("FallbackAPIServer %s: unexpected error") + return Config{}, nil, fmt.Errorf("FallbackAPIServer %s: unexpected error", nickname) } } diff --git a/config_test.go b/config_test.go index 4c5a15f..96a69aa 100644 --- a/config_test.go +++ b/config_test.go @@ -103,8 +103,7 @@ func TestConfig(t *testing.T) { config, deprecations, err = CleanConfig(&rawConfig) assert.Nil(t, err) assert.Empty(t, deprecations) - assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].SessionURL) - assert.Equal(t, "https://drasl.example.com", config.FallbackAPIServers[0].AccountURL) + assert.True(t, config.FallbackAPIServers[0].URLs.IsArg3()) assert.Equal(t, "https://drasl.example.com/editskin", config.FallbackAPIServers[0].SetSkinURL) assert.True(t, config.RegistrationUsernamePassword.CreateNewPlayer.Allow) assert.Equal(t, 1, len(config.RegistrationUsernamePassword.ImportExistingPlayer)) @@ -123,62 +122,6 @@ func TestConfig(t *testing.T) { } assertUnclean(t, rawConfig) - rawConfig = configTestRawConfig(sd) - testFallbackAPIServer := rawFallbackAPIServerConfig{ - Nickname: Ptr("Nickname"), - SessionURL: Ptr("https://δρασλ.example.com/"), - AccountURL: Ptr("https://δρασλ.example.com/"), - ServicesURL: Ptr("https://δρασλ.example.com/"), - SkinDomains: Ptr([]string{"δρασλ.example.com"}), - } - fb := testFallbackAPIServer - rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} - config, deprecations, err = CleanConfig(&rawConfig) - assert.Empty(t, deprecations) - assert.Nil(t, err) - - assert.Equal(t, 1, len(config.FallbackAPIServers)) - assert.Equal(t, *fb.Nickname, config.FallbackAPIServers[0].Nickname) - assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].SessionURL) - assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].AccountURL) - assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].ServicesURL) - assert.Equal(t, []string{"xn--mxafwwl.example.com"}, config.FallbackAPIServers[0].SkinDomains) - - fb = testFallbackAPIServer - fb.Nickname = Ptr("") - rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} - assertUnclean(t, rawConfig) - - fb = testFallbackAPIServer - fb.SessionURL = Ptr("") - rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} - assertUnclean(t, rawConfig) - - fb = testFallbackAPIServer - fb.SessionURL = Ptr(":invalid URL") - rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} - assertUnclean(t, rawConfig) - - fb = testFallbackAPIServer - fb.AccountURL = Ptr("") - rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} - assertUnclean(t, rawConfig) - - fb = testFallbackAPIServer - fb.AccountURL = Ptr(":invalid URL") - rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} - assertUnclean(t, rawConfig) - - fb = testFallbackAPIServer - fb.ServicesURL = Ptr("") - rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} - assertUnclean(t, rawConfig) - - fb = testFallbackAPIServer - fb.ServicesURL = Ptr(":invalid URL") - rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{fb} - assertUnclean(t, rawConfig) - // New [[ImportExistingPlayer]] array-of-tables form: no deprecation. rawConfig = configTestRawConfig(sd) rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ @@ -289,10 +232,10 @@ func TestConfigDeprecations(t *testing.T) { sd := Unwrap(os.MkdirTemp("", "tmp")) defer os.RemoveAll(sd) - // ForwardSkins (global, deprecated in 4.0.0) + // ForwardSkins (deprecated in 4.0.0) - // Global ForwardSkins = false with a FallbackAPIServer that does not set - // per-server ForwardSkins: the global value migrates to the server. + // The global ForwardSkins is still respected if a FallbackAPIServer does + // not set FallbackAPIServer.ForwardSkins { rawConfig := configTestRawConfig(sd) rawConfig.ForwardSkins = Ptr(false) @@ -305,8 +248,7 @@ func TestConfigDeprecations(t *testing.T) { assert.False(t, config.FallbackAPIServers[0].ForwardSkins) } - // Global ForwardSkins = false but per-server ForwardSkins = true: per-server - // wins (the deprecated global is only a fallback when per-server is unset). + // FallbackAPIServer.ForwardSkins wins over ForwardSkins { rawConfig := configTestRawConfig(sd) rawConfig.ForwardSkins = Ptr(false) @@ -319,22 +261,10 @@ func TestConfigDeprecations(t *testing.T) { assert.True(t, config.FallbackAPIServers[0].ForwardSkins) } - // No global ForwardSkins: no deprecation, per-server defaults to true. - { - rawConfig := configTestRawConfig(sd) - rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ - {Nickname: Ptr("Mojang"), SessionURL: Ptr("https://sessionserver.mojang.com"), AccountURL: Ptr("https://api.mojang.com"), ServicesURL: Ptr("https://api.minecraftservices.com")}, - } - config, deprecations, err := CleanConfig(&rawConfig) - assert.Nil(t, err) - assert.False(t, hasDeprecation(deprecations, "ForwardSkins")) - assert.True(t, config.FallbackAPIServers[0].ForwardSkins) - } - // AllowAddingDeletingPlayers (deprecated in 4.0.0) - // AllowAddingDeletingPlayers = false clears CreateNewPlayer.Allow and - // ImportExistingPlayer entries. + // AllowAddingDeletingPlayers = false overrides CreateNewPlayer.Allow to + // false and clears ImportExistingPlayer entries. { rawConfig := configTestRawConfig(sd) rawConfig.AllowAddingDeletingPlayers = Ptr(false) @@ -405,9 +335,9 @@ func TestConfigDeprecations(t *testing.T) { // RegistrationExistingPlayer (deprecated in 4.0.0) - // RegistrationExistingPlayer with Allow=true migrates to + // RegistrationExistingPlayer with Allow = true migrates to // RegistrationUsernamePassword.ImportExistingPlayer only when the legacy - // [ImportExistingPlayer] form (with Allow=true) is also present, so a + // [ImportExistingPlayer] form (with Allow = true) is also present, so a // synthesized FallbackAPIServer exists to reference. { rawConfig := configTestRawConfig(sd) @@ -440,8 +370,8 @@ func TestConfigDeprecations(t *testing.T) { assert.True(t, ep.RequireInvite) } - // RegistrationExistingPlayer with Allow=false produces no ImportExistingPlayer - // entry (but deprecation is emitted). + // RegistrationExistingPlayer with Allow = false produces no + // ImportExistingPlayer entry (but deprecation is emitted). { rawConfig := configTestRawConfig(sd) rawConfig.RegistrationExistingPlayer = &rawRegistrationExistingPlayerConfig{ @@ -481,8 +411,8 @@ func TestConfigDeprecations(t *testing.T) { // RegistrationOIDC.RequireInvite (deprecated in 4.0.0) - // OIDC RequireInvite alone (no global RegistrationNewPlayer, no explicit - // CreateNewPlayer) migrates to CreateNewPlayer.RequireInvite via OR. + // RegistrationOIDC.RequireInvite migrates to + // RegistrationOIDC.CreateNewPlayer.RequireInvite { rawConfig := configTestRawConfig(sd) rawConfig.RegistrationOIDC = []rawRegistrationOIDCConfig{ @@ -495,8 +425,9 @@ func TestConfigDeprecations(t *testing.T) { assert.True(t, config.RegistrationOIDC[0].CreateNewPlayer.RequireInvite) } - // OR semantics for CreateNewPlayer: global RequireInvite=false + per-OIDC - // RequireInvite=true => result true. + // Invite required for OIDC registration with new player when + // RegistrationNewPlayer.RequireInvite = false and + // RegistrationOIDC.RequireInvite = true { rawConfig := configTestRawConfig(sd) rawConfig.RegistrationNewPlayer = &rawRegistrationNewPlayerConfig{ @@ -512,8 +443,9 @@ func TestConfigDeprecations(t *testing.T) { assert.True(t, config.RegistrationOIDC[0].CreateNewPlayer.RequireInvite) } - // OR semantics for CreateNewPlayer: global RequireInvite=true + per-OIDC - // RequireInvite=false => result true. + // Invite required for OIDC registration with new player when + // RegistrationNewPlayer.RequireInvite = true and + // RegistrationOIDC.RequireInvite = false { rawConfig := configTestRawConfig(sd) rawConfig.RegistrationNewPlayer = &rawRegistrationNewPlayerConfig{ @@ -529,8 +461,8 @@ func TestConfigDeprecations(t *testing.T) { assert.True(t, config.RegistrationOIDC[0].CreateNewPlayer.RequireInvite) } - // Explicit OIDC CreateNewPlayer.RequireInvite takes precedence; the deprecated - // per-OIDC RequireInvite is ignored for CreateNewPlayer (but still warned). + // RegistrationOIDC.CreateNewPlayer.RequireInvite wins over + // RegistrationOIDC.RequireInvite { rawConfig := configTestRawConfig(sd) rawConfig.RegistrationOIDC = []rawRegistrationOIDCConfig{ @@ -547,9 +479,9 @@ func TestConfigDeprecations(t *testing.T) { assert.False(t, config.RegistrationOIDC[0].CreateNewPlayer.RequireInvite) } - // OR semantics for ImportExistingPlayer: per-OIDC RequireInvite migrates into - // the synthesized ImportExistingPlayer entry, OR'd with the global - // RegistrationExistingPlayer.RequireInvite. + // Invite required for synthesized RegistrationOIDC.ImportExistingPlayer + // when RegistrationOIDC.RequireInvite = true and + // RegistrationExistingPlayer.RequireInvite = false { rawConfig := configTestRawConfig(sd) rawConfig.RegistrationExistingPlayer = &rawRegistrationExistingPlayerConfig{ @@ -578,8 +510,9 @@ func TestConfigDeprecations(t *testing.T) { assert.Equal(t, "Mojang", oidc.ImportExistingPlayer[0].FallbackAPIServerNickname) } - // Both global RegistrationExistingPlayer.RequireInvite=true and per-OIDC - // RequireInvite=false => OR yields true. + // Invite required for synthesized RegistrationOIDC.ImportExistingPlayer + // when RegistrationOIDC.RequireInvite = false and + // RegistrationExistingPlayer.RequireInvite = true { rawConfig := configTestRawConfig(sd) rawConfig.RegistrationExistingPlayer = &rawRegistrationExistingPlayerConfig{ @@ -608,8 +541,8 @@ func TestConfigDeprecations(t *testing.T) { // Legacy [ImportExistingPlayer] single-table form (deprecated in 4.0.0) - // Legacy form with Allow=true and no matching [[FallbackAPIServers]] entry: - // synthesize one, emit a deprecation. + // Legacy form with Allow = true and no matching [[FallbackAPIServers]] + // entry: synthesize one, emit a deprecation. { rawConfig := configTestRawConfig(sd) rawConfig.ImportExistingPlayer = rawImportExistingPlayer{ @@ -632,16 +565,17 @@ func TestConfigDeprecations(t *testing.T) { assert.Len(t, deprecations, 1) assert.Equal(t, 1, len(config.FallbackAPIServers)) assert.Equal(t, "Mojang", config.FallbackAPIServers[0].Nickname) - assert.Equal(t, "https://sessionserver.mojang.com", config.FallbackAPIServers[0].SessionURL) - assert.Equal(t, "https://api.mojang.com", config.FallbackAPIServers[0].AccountURL) + legacy2 := config.FallbackAPIServers[0].URLs.MustArg3() + assert.Equal(t, "https://sessionserver.mojang.com", legacy2.SessionURL) + assert.Equal(t, "https://api.mojang.com", legacy2.AccountURL) assert.Equal(t, "https://www.minecraft.net/msaprofile/mygames/editskin", config.FallbackAPIServers[0].SetSkinURL) assert.Equal(t, 1, len(config.ImportExistingPlayer)) assert.Equal(t, "Mojang", config.ImportExistingPlayer[0].FallbackAPIServerNickname) assert.True(t, config.ImportExistingPlayer[0].RequireSkinVerification) } - // Legacy form with Allow=true and a matching [[FallbackAPIServers]] entry: - // do not duplicate the entry; reference the existing one. + // Legacy form with Allow = true and a matching [[FallbackAPIServers]] + // entry: do not duplicate the entry; reference the existing one. { rawConfig := configTestRawConfig(sd) rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ @@ -665,11 +599,13 @@ func TestConfigDeprecations(t *testing.T) { assert.True(t, hasDeprecation(deprecations, "ImportExistingPlayer")) assert.Len(t, deprecations, 1) assert.Equal(t, 1, len(config.FallbackAPIServers)) - assert.Equal(t, "https://api.minecraftservices.com", config.FallbackAPIServers[0].ServicesURL) + legacy3 := config.FallbackAPIServers[0].URLs.MustArg3() + assert.Equal(t, "https://api.minecraftservices.com", legacy3.ServicesURL) assert.Equal(t, 1, len(config.ImportExistingPlayer)) } - // Legacy form with Allow=false: no entries, no synthesized FallbackAPIServer. + // Legacy form with Allow = false: no entries, no synthesized + // FallbackAPIServer. { rawConfig := configTestRawConfig(sd) rawConfig.ImportExistingPlayer = rawImportExistingPlayer{ @@ -683,7 +619,7 @@ func TestConfigDeprecations(t *testing.T) { assert.Equal(t, 0, len(config.ImportExistingPlayer)) } - // Legacy form with Allow=true but empty Nickname should fail. + // Legacy form with Allow = true but empty Nickname should fail. { rawConfig := configTestRawConfig(sd) rawConfig.ImportExistingPlayer = rawImportExistingPlayer{ @@ -748,3 +684,177 @@ func TestConfigDeprecations(t *testing.T) { assert.True(t, config.RegistrationOIDC[0].ImportExistingPlayer[0].RequireInvite) } } + +func TestConfigFallbackAPIServerURLs(t *testing.T) { + t.Parallel() + sd := Unwrap(os.MkdirTemp("", "tmp")) + defer os.RemoveAll(sd) + + // AuthlibInjectorURL form + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + {Nickname: Ptr("ALI"), AuthlibInjectorURL: Ptr("https://littleskin.cn/api/yggdrasil/")}, + } + config, deprecations, err := CleanConfig(&rawConfig) + assert.Nil(t, err) + assert.Empty(t, deprecations) + assert.True(t, config.FallbackAPIServers[0].URLs.IsArg2()) + assert.Equal(t, "https://littleskin.cn/api/yggdrasil", config.FallbackAPIServers[0].URLs.MustArg2().AuthlibInjectorURL) + } + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + {Nickname: Ptr("ALI"), AuthlibInjectorURL: Ptr("https://δρασλ.example.com/")}, + } + config, _, err := CleanConfig(&rawConfig) + assert.Nil(t, err) + assert.Equal(t, "https://xn--mxafwwl.example.com", config.FallbackAPIServers[0].URLs.MustArg2().AuthlibInjectorURL) + } + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + {Nickname: Ptr("ALI"), AuthlibInjectorURL: Ptr(":invalid")}, + } + assertUnclean(t, rawConfig) + } + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + {Nickname: Ptr("ALI"), AuthlibInjectorURL: Ptr("")}, + } + assertUnclean(t, rawConfig) + } + + // DiscoveryMinecraftClientURL form + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + {Nickname: Ptr("Discovery"), DiscoveryMinecraftClientURL: Ptr("https://discovery.minecraftservices.com/minecraft/client/")}, + } + config, deprecations, err := CleanConfig(&rawConfig) + assert.Nil(t, err) + assert.Empty(t, deprecations) + assert.True(t, config.FallbackAPIServers[0].URLs.IsArg1()) + assert.Equal(t, "https://discovery.minecraftservices.com/minecraft/client", config.FallbackAPIServers[0].URLs.MustArg1().DiscoveryMinecraftClientURL) + } + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + {Nickname: Ptr("Discovery"), DiscoveryMinecraftClientURL: Ptr("https://δρασλ.example.com/minecraft/client")}, + } + config, _, err := CleanConfig(&rawConfig) + assert.Nil(t, err) + assert.Equal(t, "https://xn--mxafwwl.example.com/minecraft/client", config.FallbackAPIServers[0].URLs.MustArg1().DiscoveryMinecraftClientURL) + } + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + {Nickname: Ptr("Discovery"), DiscoveryMinecraftClientURL: Ptr(":invalid")}, + } + assertUnclean(t, rawConfig) + } + + // Legacy form + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + { + Nickname: Ptr("Legacy"), + SessionURL: Ptr("https://δρασλ.example.com/"), + AccountURL: Ptr("https://δρασλ.example.com/"), + ServicesURL: Ptr("https://δρασλ.example.com/"), + SkinDomains: Ptr([]string{"δρασλ.example.com"}), + }, + } + config, deprecations, err := CleanConfig(&rawConfig) + assert.Nil(t, err) + assert.Empty(t, deprecations) + assert.True(t, config.FallbackAPIServers[0].URLs.IsArg3()) + legacy := config.FallbackAPIServers[0].URLs.MustArg3() + assert.Equal(t, "https://xn--mxafwwl.example.com", legacy.SessionURL) + assert.Equal(t, "https://xn--mxafwwl.example.com", legacy.AccountURL) + assert.Equal(t, "https://xn--mxafwwl.example.com", legacy.ServicesURL) + assert.Equal(t, []string{"xn--mxafwwl.example.com"}, legacy.SkinDomains) + } + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + { + Nickname: Ptr("Legacy"), + SessionURL: Ptr(":invalid"), + AccountURL: Ptr("https://api.mojang.com"), + ServicesURL: Ptr("https://api.minecraftservices.com"), + }, + } + assertUnclean(t, rawConfig) + } + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + { + Nickname: Ptr(""), + SessionURL: Ptr("https://sessionserver.mojang.com"), + AccountURL: Ptr("https://api.mojang.com"), + ServicesURL: Ptr("https://api.minecraftservices.com"), + }, + } + assertUnclean(t, rawConfig) + } + + // The three forms are mutually exclusive. + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + { + Nickname: Ptr("Both"), + SessionURL: Ptr("https://sessionserver.mojang.com"), + AccountURL: Ptr("https://api.mojang.com"), + ServicesURL: Ptr("https://api.minecraftservices.com"), + AuthlibInjectorURL: Ptr("https://littleskin.cn/api/yggdrasil"), + }, + } + _, _, err := CleanConfig(&rawConfig) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "legacy URLs and AuthlibInjectorURL") + } + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + { + Nickname: Ptr("Both"), + SessionURL: Ptr("https://sessionserver.mojang.com"), + AccountURL: Ptr("https://api.mojang.com"), + ServicesURL: Ptr("https://api.minecraftservices.com"), + DiscoveryMinecraftClientURL: Ptr("https://discovery.minecraftservices.com/minecraft/client"), + }, + } + _, _, err := CleanConfig(&rawConfig) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "legacy URLs and DiscoveryMinecraftClientURL") + } + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + { + Nickname: Ptr("Both"), + AuthlibInjectorURL: Ptr("https://littleskin.cn/api/yggdrasil"), + DiscoveryMinecraftClientURL: Ptr("https://discovery.minecraftservices.com/minecraft/client"), + }, + } + _, _, err := CleanConfig(&rawConfig) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "AuthlibInjectorURL and DiscoveryMinecraftClientURL") + } + + // None supplied + { + rawConfig := configTestRawConfig(sd) + rawConfig.FallbackAPIServers = []rawFallbackAPIServerConfig{ + {Nickname: Ptr("Lonely")}, + } + _, _, err := CleanConfig(&rawConfig) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "must supply either an AuthlibInjectorURL, a DiscoveryMinecraftClientURL, or legacy URLs") + } +} diff --git a/discovery_test.go b/discovery_test.go index 828b4e3..ada4cec 100644 --- a/discovery_test.go +++ b/discovery_test.go @@ -24,7 +24,7 @@ func TestDiscoveryMinecraftClient(t *testing.T) { rec := ts.Get(t, ts.Server, path, nil, nil) assert.Equal(t, http.StatusOK, rec.Code) - var response discoveryResponse + var response DiscoveryResponse assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response)) assert.Equal(t, "prod", response.Environment) assert.Equal(t, "minecraft", response.Product) diff --git a/fallback_api_server_test.go b/fallback_api_server_test.go new file mode 100644 index 0000000..a0f74c4 --- /dev/null +++ b/fallback_api_server_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "crypto/rsa" + "net/http" + "testing" + + "github.com/samber/mo" + "github.com/stretchr/testify/assert" +) + +// fallbackKeyContains checks whether a set of rsa.PublicKey contains a key +// equal to target, using rsa.PublicKey.Equal for value comparison (since +// mapset uses == which compares *big.Int pointers, not values). +func fallbackKeyContains(set interface{ ToSlice() []rsa.PublicKey }, target rsa.PublicKey) bool { + for _, k := range set.ToSlice() { + if k.Equal(&target) { + return true + } + } + return false +} + +// TestFallbackAPIServerLegacy verifies that a FallbackAPIServer configured +// with legacy SessionURL/AccountURL/ServicesURL/SkinDomains correctly derives +// its endpoint URLs, public keys, skin domains, and texture valid URIs. +func TestFallbackAPIServerLegacy(t *testing.T) { + t.Parallel() + ts := &TestSuite{} + + auxConfig := testConfig() + ts.SetupAux(auxConfig) + + const legacySkinDomain = "legacy.example.com" + config := testConfig() + fallback := ts.ToFallbackAPIServerLegacy(ts.AuxApp, "Aux") + legacy := fallback.URLs.MustArg3() + legacy.SkinDomains = []string{legacySkinDomain} + fallback.URLs = mo.NewEither3Arg3[ + fallbackAPIServerDiscoveryConfig, + fallbackAPIServerAuthlibInjectorConfig, + fallbackAPIServerLegacyConfig, + ](legacy) + config.FallbackAPIServers = []FallbackAPIServerConfig{fallback} + ts.Setup(config) + defer ts.Teardown() + + ts.CreateTestUser(t, ts.AuxApp, ts.AuxServer, TEST_USERNAME) + + fb, ok := ts.App.FallbackAPIServers["Aux"] + assert.True(t, ok, "FallbackAPIServer 'Aux' should be registered") + + assert.Equal(t, ts.AuxApp.SessionURL+"/session/minecraft/profile", fb.SessionGetProfileByIDURL) + assert.Equal(t, ts.AuxApp.SessionURL+"/session/minecraft/hasJoined", fb.SessionVerifyURL) + assert.Equal(t, ts.AuxApp.AccountURL+"/profiles/minecraft", fb.ProfilesGetManyByNameURL) + + // The aux's public key should be present in both key sets, fetched from + // the aux's /publickeys endpoint. + assert.Equal(t, 1, fb.ProfilePropertyKeys.Cardinality()) + assert.Equal(t, 1, fb.PlayerCertificateKeys.Cardinality()) + assert.True(t, fallbackKeyContains(fb.ProfilePropertyKeys, ts.AuxApp.PrivateKey.PublicKey)) + assert.True(t, fallbackKeyContains(fb.PlayerCertificateKeys, ts.AuxApp.PrivateKey.PublicKey)) + + // Skin domains and texture valid URIs derived from the configured + // SkinDomains list. + assert.True(t, fb.SkinDomains.Contains(legacySkinDomain)) + assert.True(t, fb.GetTextureValidURIs.Contains("https://"+legacySkinDomain+"/")) + assert.True(t, fb.GetTextureValidURIs.Contains("http://"+legacySkinDomain+"/")) + + // Look up an aux player by UUID through the main server. + { + var player Player + assert.Nil(t, ts.AuxApp.DB.First(&player, "name = ?", TEST_USERNAME).Error) + + rec := ts.Get(t, ts.Server, "/minecraft/profile/lookup/"+player.UUID, nil, nil) + assert.Equal(t, http.StatusOK, rec.Code) + } +} + +// TestFallbackAPIServerDiscovery verifies that a FallbackAPIServer configured +// with a DiscoveryMinecraftClientURL correctly derives its endpoint URLs and +// public keys from the aux server's discovery document. +func TestFallbackAPIServerDiscovery(t *testing.T) { + t.Parallel() + ts := &TestSuite{} + + auxConfig := testConfig() + ts.SetupAux(auxConfig) + + config := testConfig() + config.FallbackAPIServers = []FallbackAPIServerConfig{ + ts.ToFallbackAPIServerDiscovery(ts.AuxApp, "Aux"), + } + ts.Setup(config) + defer ts.Teardown() + + ts.CreateTestUser(t, ts.AuxApp, ts.AuxServer, TEST_USERNAME) + + fb, ok := ts.App.FallbackAPIServers["Aux"] + assert.True(t, ok, "FallbackAPIServer 'Aux' should be registered") + + // URLs derived from the aux's discovery document. + assert.Equal(t, ts.AuxApp.SessionURL+"/session/minecraft/profile", fb.SessionGetProfileByIDURL) + assert.Equal(t, ts.AuxApp.SessionURL+"/session/minecraft/hasJoined", fb.SessionVerifyURL) + assert.Equal(t, ts.AuxApp.AccountURL+"/profiles/minecraft", fb.ProfilesGetManyByNameURL) + + // The aux's public key should be present in both key sets. + assert.Equal(t, 1, fb.ProfilePropertyKeys.Cardinality()) + assert.Equal(t, 1, fb.PlayerCertificateKeys.Cardinality()) + assert.True(t, fallbackKeyContains(fb.ProfilePropertyKeys, ts.AuxApp.PrivateKey.PublicKey)) + assert.True(t, fallbackKeyContains(fb.PlayerCertificateKeys, ts.AuxApp.PrivateKey.PublicKey)) + + // Look up an aux player by UUID through the main server. + { + var player Player + assert.Nil(t, ts.AuxApp.DB.First(&player, "name = ?", TEST_USERNAME).Error) + + rec := ts.Get(t, ts.Server, "/minecraft/profile/lookup/"+player.UUID, nil, nil) + assert.Equal(t, http.StatusOK, rec.Code) + } +} + +// TestFallbackAPIServerAuthlibInjector verifies that a FallbackAPIServer +// configured with an AuthlibInjectorURL correctly derives its endpoint URLs, +// public keys, skin domains, and texture valid URIs from the aux server's +// authlib-injector root endpoint. +func TestFallbackAPIServerAuthlibInjector(t *testing.T) { + t.Parallel() + ts := &TestSuite{} + + auxConfig := testConfig() + ts.SetupAux(auxConfig) + + config := testConfig() + config.FallbackAPIServers = []FallbackAPIServerConfig{ + ts.ToFallbackAPIServerAuthlibInjector(ts.AuxApp, "Aux"), + } + ts.Setup(config) + defer ts.Teardown() + + ts.CreateTestUser(t, ts.AuxApp, ts.AuxServer, TEST_USERNAME) + + fb, ok := ts.App.FallbackAPIServers["Aux"] + assert.True(t, ok, "FallbackAPIServer 'Aux' should be registered") + + // URLs derived from the aux's authlib-injector location. + assert.Equal(t, ts.AuxApp.AuthlibInjectorURL+"/sessionserver/session/minecraft/profile", fb.SessionGetProfileByIDURL) + assert.Equal(t, ts.AuxApp.AuthlibInjectorURL+"/sessionserver/session/minecraft/hasJoined", fb.SessionVerifyURL) + assert.Equal(t, ts.AuxApp.AuthlibInjectorURL+"/api/profiles/minecraft", fb.ProfilesGetManyByNameURL) + + // The aux's public key should be present in both key sets (ALI form + // populates both from the single SignaturePublickey). + assert.Equal(t, 1, fb.ProfilePropertyKeys.Cardinality()) + assert.Equal(t, 1, fb.PlayerCertificateKeys.Cardinality()) + assert.True(t, fallbackKeyContains(fb.ProfilePropertyKeys, ts.AuxApp.PrivateKey.PublicKey)) + assert.True(t, fallbackKeyContains(fb.PlayerCertificateKeys, ts.AuxApp.PrivateKey.PublicKey)) + + // Skin domains and texture valid URIs derived from the aux's + // authlib-injector response. + assert.True(t, fb.SkinDomains.Contains(ts.AuxApp.Config.Domain)) + assert.True(t, fb.GetTextureValidURIs.Contains("https://"+ts.AuxApp.Config.Domain+"/")) + assert.True(t, fb.GetTextureValidURIs.Contains("http://"+ts.AuxApp.Config.Domain+"/")) + + // Functional: look up an aux player by UUID through the main server. + { + var player Player + assert.Nil(t, ts.AuxApp.DB.First(&player, "name = ?", TEST_USERNAME).Error) + + rec := ts.Get(t, ts.Server, "/minecraft/profile/lookup/"+player.UUID, nil, nil) + assert.Equal(t, http.StatusOK, rec.Code) + } +} diff --git a/front_test.go b/front_test.go index 60436ff..34d3941 100644 --- a/front_test.go +++ b/front_test.go @@ -35,12 +35,7 @@ func setupExistingPlayerTS(t *testing.T, requireSkinVerification bool, requireIn config.CreateNewPlayer.Allow = false config.RegistrationUsernamePassword.CreateNewPlayer.Allow = false config.FallbackAPIServers = []FallbackAPIServerConfig{ - { - Nickname: "Aux", - SessionURL: ts.AuxApp.SessionURL, - AccountURL: ts.AuxApp.AccountURL, - ServicesURL: ts.AuxApp.ServicesURL, - }, + ts.ToFallbackAPIServerAuthlibInjector(ts.AuxApp, "Aux"), } config.RegistrationUsernamePassword.ImportExistingPlayer = []regImportExistingPlayerConfig{ { diff --git a/services_test.go b/services_test.go index 9f37ac7..9df6407 100644 --- a/services_test.go +++ b/services_test.go @@ -26,15 +26,9 @@ func TestServices(t *testing.T) { ts.SetupAux(auxConfig) config := testConfig() - config.FallbackAPIServers = []FallbackAPIServerConfig{ - { - Nickname: "Aux", - SessionURL: ts.AuxApp.SessionURL, - AccountURL: ts.AuxApp.AccountURL, - ServicesURL: ts.AuxApp.ServicesURL, - ForwardSkins: false, - }, - } + fallback := ts.ToFallbackAPIServerAuthlibInjector(ts.AuxApp, "Aux") + fallback.ForwardSkins = false + config.FallbackAPIServers = []FallbackAPIServerConfig{fallback} ts.Setup(config) defer ts.Teardown() diff --git a/test_suite_test.go b/test_suite_test.go index 7218f2d..c79a2ff 100644 --- a/test_suite_test.go +++ b/test_suite_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "github.com/labstack/echo/v5" + "github.com/samber/mo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "io" @@ -21,7 +22,6 @@ import ( "os" "strings" "testing" - "time" ) var _ = os.Setenv("DRASL_TEST", "1") @@ -114,6 +114,14 @@ func (ts *TestSuite) SetupAux(config *Config) { config.StateDirectory = tempStateDirectory config.DataDirectory = "." + // Bind the listener first so we know the port before setup, allowing + // the aux server's discovery handler (which precomputes its response at + // MakeServer time) to bake in reachable localhost URLs. + listener := Unwrap(net.Listen("tcp", ":0")) + auxServerAddr := listener.Addr().(*net.TCPAddr) + baseURL := fmt.Sprintf("http://localhost:%d", auxServerAddr.Port) + config.BaseURL = baseURL + auxConfig := *config ts.AuxConfig = &auxConfig ts.AuxApp = setup(config) @@ -122,43 +130,68 @@ func (ts *TestSuite) SetupAux(config *Config) { ts.AuxServer = ts.AuxApp.MakeServer() - // Get the aux server's address - addrChan := make(chan net.Addr, 1) ctx, cancel := context.WithCancel(context.Background()) ts.AuxServerCancel = cancel go func() { Ignore(echo.StartConfig{ - Address: ":0", - HideBanner: true, - HidePort: true, - ListenerAddrFunc: func(addr net.Addr) { addrChan <- addr }, + Listener: listener, + HideBanner: true, + HidePort: true, }.Start(ctx, ts.AuxServer)) }() - select { - case addr := <-addrChan: - ts.AuxServerAddr = addr.(*net.TCPAddr) - case <-time.After(1 * time.Second): - panic("timeout waiting for aux server to start") - } - - baseURL := fmt.Sprintf("http://localhost:%d", ts.AuxServerAddr.Port) - ts.AuxApp.Config.BaseURL = baseURL + ts.AuxServerAddr = auxServerAddr ts.AuxApp.FrontEndURL = baseURL ts.AuxApp.AccountURL = Unwrap(url.JoinPath(baseURL, "account")) ts.AuxApp.AuthURL = Unwrap(url.JoinPath(baseURL, "auth")) ts.AuxApp.ServicesURL = Unwrap(url.JoinPath(baseURL, "services")) ts.AuxApp.SessionURL = Unwrap(url.JoinPath(baseURL, "session")) ts.AuxApp.TexturesURL = Unwrap(url.JoinPath(baseURL, "textures")) + ts.AuxApp.AuthlibInjectorURL = Unwrap(url.JoinPath(baseURL, "authlib-injector")) ts.AuxApp.DiscoveryURL = Unwrap(url.JoinPath(baseURL, "discovery")) } -func (ts *TestSuite) ToFallbackAPIServer(app *App, nickname string) FallbackAPIServerConfig { +func (ts *TestSuite) ToFallbackAPIServerLegacy(app *App, nickname string) FallbackAPIServerConfig { return FallbackAPIServerConfig{ - Nickname: nickname, - SessionURL: app.SessionURL, - AccountURL: app.AccountURL, - ServicesURL: app.ServicesURL, + Nickname: nickname, + URLs: mo.NewEither3Arg3[ + fallbackAPIServerDiscoveryConfig, + fallbackAPIServerAuthlibInjectorConfig, + fallbackAPIServerLegacyConfig, + ](fallbackAPIServerLegacyConfig{ + SessionURL: app.SessionURL, + AccountURL: app.AccountURL, + ServicesURL: app.ServicesURL, + SkinDomains: []string{}, + }), + CacheTTLSeconds: 0, + } +} + +func (ts *TestSuite) ToFallbackAPIServerAuthlibInjector(app *App, nickname string) FallbackAPIServerConfig { + return FallbackAPIServerConfig{ + Nickname: nickname, + URLs: mo.NewEither3Arg2[ + fallbackAPIServerDiscoveryConfig, + fallbackAPIServerAuthlibInjectorConfig, + fallbackAPIServerLegacyConfig, + ](fallbackAPIServerAuthlibInjectorConfig{ + AuthlibInjectorURL: app.AuthlibInjectorURL, + }), + CacheTTLSeconds: 0, + } +} + +func (ts *TestSuite) ToFallbackAPIServerDiscovery(app *App, nickname string) FallbackAPIServerConfig { + return FallbackAPIServerConfig{ + Nickname: nickname, + URLs: mo.NewEither3Arg1[ + fallbackAPIServerDiscoveryConfig, + fallbackAPIServerAuthlibInjectorConfig, + fallbackAPIServerLegacyConfig, + ](fallbackAPIServerDiscoveryConfig{ + DiscoveryMinecraftClientURL: app.DiscoveryURL + "/minecraft/client", + }), CacheTTLSeconds: 0, } }