Files
Felis/internal/config/config_test.go
T
flyemoji fd062882ed feat(nano): give a Mojang player's name back to them, by prefixing the squatter
A premium player and a third-party player sharing a username could not both be
online. Whichever logged in second was kicked with "You are already connected to
this proxy!" -- even though the UUID rewrite had already made them two distinct
players on the backend. Velocity's player registry is keyed on the NAME (lowercased),
not the UUID, so two identities holding one name are one player as far as the proxy
is concerned, and the reclaim invariant the rewrite buys is invisible to it.

The fix needs no plugin and no state, because Velocity honours the name in the
hasJoined RESPONSE rather than pinning the one the client sent at login-start --
established by a real login, not by reading the source. So the multiplexer hands
back a different name and the collision is simply gone.

A third-party player whose name belongs to a Mojang account now joins as
PREFIX_name (LS_steve). Everyone else keeps their own name: the rename fires only
on an actual collision, decided by asking api.mojang.com whether the name is
registered. The name's owner is never the one renamed, which is 正版优先 falling out
for free -- the identity source is never rewritten, so there is no policy to encode
and no 30-day hold to track.

The premium-name answer is cached asymmetrically, because the two directions have
very different costs. "Taken" is nearly permanent (Mojang does not recycle names) and
is trusted for a day; "free" can stop being true the moment someone buys that name,
and a stale "free" leaves a squatter holding a name its real owner has just bought,
so it is trusted for ten minutes. A lookup that fails with nothing cached fails
CLOSED -- assume premium, rename the third-party player: a Mojang outage must not
become an opportunity to hold someone else's name, and being wrong that way costs a
cosmetic prefix while being wrong the other way bounces the name's owner off the
proxy. The lookup gets its own 2s client rather than sharing the 5s auth client,
since it is a SECOND Mojang round-trip on a login that already spent one.

prefix is a required, unique, 1-4 character config field rather than something
derived from the tag, because it is player-visible and no derivation can know that
"littleskin" is meant to read LS. Two sources sharing a prefix would rewrite their
same-named players onto one name, so uniqueness is enforced case-insensitively --
the proxy folds case, and LS/ls would collide there while reading as distinct here.

Also close a pre-existing hole on the path this touches: a third-party source's
profile name was relayed verbatim, so a hostile or sloppy Yggdrasil root could put
"§4admin", an empty string, or 200 characters straight into the proxy's player list.
The name is now checked against the Minecraft username charset and a bad one is a 204,
the same way a bad UUID already was.

Verified end to end on the deploy host (Velocity 3.5.1 + Paper 26.2), both branches:

  premium FLYEMOJ1     -> 195fadbd-f72e-4b9b-9f8f-f92586fe16ad, name unchanged
  LittleSkin FLYEMOJ1  -> LS_FLYEMOJ1, f1b7b6ae-f250-348a-b069-a2ec0fcae668
  both online at once, zero "already connected" rejections
  LittleSkin FelisNyaTest01 -> joins as FelisNyaTest01, no prefix, UUID still v3

The last line is the one that matters: an ordinary third-party player collides with
nobody and keeps their name, while the rewrite that keeps identities apart still ran.
Paper's "LS_FLYEMOJ1 (formerly known as li_FLYEMOJ1) joined the game" is the other
half of it -- the rename moved the player's display name and their playerdata came
along untouched, because every server-side key is the UUID and the UUID does not
depend on the name.

Known ceiling, left alone deliberately: two players of one source whose names agree
on their first 16-len(prefix)-1 characters truncate onto the same in-game name, and a
prefixed name may itself happen to be a premium name. Both cost an "already connected"
bounce, not an identity -- the UUID rewrite does not depend on the name at all.

BREAKING CHANGE: every [[auth_source]] now requires prefix = "XX" (1-4 letters or
digits, unique across sources). An existing nano felis.toml without it fails to load
with an error naming the field, rather than silently keeping the collision.
2026-07-13 13:00:54 +09:00

408 lines
12 KiB
Go

package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"felis.lolicon.best/internal/config"
)
// writeTOML writes content to a temp felis.toml and returns its path.
func writeTOML(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "felis.toml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write toml: %v", err)
}
return path
}
// Note: tests use the neutral example domain mc.example.net, never a real
// deployment domain, to keep the source tree clean of domain literals.
const validTOML = `
[server]
listen = "0.0.0.0:9090"
root_domain = "mc.example.net"
[database]
url = "postgres://felis:secret@db:5432/felis"
[velocity]
public_ip = "203.0.113.4"
service_token_ref = "felis-velocity-token"
[auth]
admin_hostname = "admin.example.net"
panel_hostname = "panel.example.net"
access_jwt_aud = "felis-panel"
[k8s]
namespace = "minecraft"
egress_mode = "loadbalancer"
metallb_pool = "192.0.2.200-250"
[registry]
url = "registry.felis.svc:5000"
build_namespace = "felis-build"
[archive]
store = "tarLocal"
local_path = "backup-pvc"
retention = "3mo"
warn_before = ["3d", "1d"]
max_local_bytes = "200Gi"
[archive.s3]
endpoint = ""
bucket = "felis-backups"
access_key_ref = ""
secret_key_ref = ""
`
func TestLoadValid(t *testing.T) {
cfg, err := config.Load(writeTOML(t, validTOML))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Listen != "0.0.0.0:9090" {
t.Errorf("listen = %q", cfg.Server.Listen)
}
if cfg.Server.RootDomain != "mc.example.net" {
t.Errorf("root_domain = %q", cfg.Server.RootDomain)
}
if cfg.Database.URL == "" {
t.Error("database url empty")
}
if cfg.Archive.Store != "tarLocal" {
t.Errorf("archive store = %q", cfg.Archive.Store)
}
if len(cfg.Archive.WarnBefore) != 2 || cfg.Archive.WarnBefore[0] != "3d" {
t.Errorf("warn_before = %v", cfg.Archive.WarnBefore)
}
if cfg.Archive.S3.Bucket != "felis-backups" {
t.Errorf("s3 bucket = %q", cfg.Archive.S3.Bucket)
}
}
func TestLoadAppliesDefaults(t *testing.T) {
cfg, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Listen != "0.0.0.0:8080" {
t.Errorf("default listen = %q, want 0.0.0.0:8080", cfg.Server.Listen)
}
if cfg.K8s.Namespace != "minecraft" {
t.Errorf("default namespace = %q, want minecraft", cfg.K8s.Namespace)
}
if cfg.K8s.EgressMode != "loadbalancer" {
t.Errorf("default egress_mode = %q", cfg.K8s.EgressMode)
}
if cfg.Archive.Store != "tarLocal" {
t.Errorf("default archive store = %q", cfg.Archive.Store)
}
}
func TestLoadRejectsMissingDatabaseURL(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
`))
if err == nil {
t.Fatal("expected error when database.url is missing")
}
}
func TestLoadRejectsMissingRootDomain(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[database]
url = "postgres://felis@db/felis"
`))
if err == nil {
t.Fatal("expected error when root_domain is missing")
}
}
func TestLoadRejectsUnknownArchiveStore(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[archive]
store = "magicbox"
`))
if err == nil {
t.Fatal("expected error for unknown archive store")
}
}
// TestLoadRejectsUnimplementedArchiveStore guards the §19/build-reality gap:
// tarS3, volumeSnapshot and longhorn are recognized store names but only
// tarLocal is implemented in this build. A config naming one of them must be
// rejected at load — otherwise felis-api boots green while the reaper CronJob
// fails every run and restore silently 503s. The error must point the operator
// at the fix (tarLocal), distinct from the "unknown store" message.
func TestLoadRejectsUnimplementedArchiveStore(t *testing.T) {
for _, store := range []string{"tarS3", "volumeSnapshot", "longhorn"} {
t.Run(store, func(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[archive]
store = "`+store+`"
`))
if err == nil {
t.Fatalf("expected error for recognized-but-unimplemented store %q", store)
}
if !strings.Contains(err.Error(), "tarLocal") {
t.Errorf("error for %q should point at the tarLocal remediation, got: %v", store, err)
}
})
}
}
// TestLoadRejectsSchemeQualifiedRegistryURL guards the §24 split-brain: the
// registry url is a bare host[:port], read scheme-tolerantly by the admin build
// path (registryHost strips the scheme) but scheme-INtolerantly by the
// user-modpack lane (deriveImageRef concatenates raw). A scheme-qualified url
// would boot felis-api green and 500 every approve while admin builds keep
// working, so it must be rejected at load with the bare-host contract spelled
// out. Both http:// and https:// are caught (the check is on "://").
func TestLoadRejectsSchemeQualifiedRegistryURL(t *testing.T) {
for _, url := range []string{"http://registry.felis.svc:5000", "https://registry.felis.svc:5000"} {
t.Run(url, func(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[registry]
url = "`+url+`"
`))
if err == nil {
t.Fatalf("expected error for scheme-qualified registry url %q", url)
}
if !strings.Contains(err.Error(), "scheme") {
t.Errorf("error for %q should explain the bare-host contract, got: %v", url, err)
}
})
}
}
// TestLoadAuthSourcesPreservesOrder pins the Felis-nano priority contract: the
// [[auth_source]] array-of-tables decodes in file order (config order = priority), which
// is why it is an array-of-tables and not a map. A map keyed by tag would load and pass
// this file yet silently reorder the sources, breaking Mojang-first federation.
func TestLoadAuthSourcesPreservesOrder(t *testing.T) {
cfg, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "littleskin"
prefix = "LS"
url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined"
[[auth_source]]
tag = "guild"
prefix = "GD"
url = "https://guild.example.net/sessionserver/session/minecraft/hasJoined"
`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.AuthSources) != 2 {
t.Fatalf("auth sources = %d, want 2", len(cfg.AuthSources))
}
if cfg.AuthSources[0].Tag != "littleskin" || cfg.AuthSources[1].Tag != "guild" {
t.Errorf("source order = %q,%q, want littleskin,guild", cfg.AuthSources[0].Tag, cfg.AuthSources[1].Tag)
}
}
// TestLoadRejectsAuthSourceIdentityKey guards the crown-jewel invariant structurally: there
// is no identity/trusted field on AuthSourceConfig, so an attempt to set one is an unknown
// key and Load rejects it loudly. A config can therefore never mint a source whose
// self-asserted UUIDs are trusted verbatim — the impersonation hole stays closed.
func TestLoadRejectsAuthSourceIdentityKey(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "evil"
url = "https://evil.example.net/hasJoined"
identity = true
`))
if err == nil {
t.Fatal("expected error for an identity= key on [[auth_source]]")
}
}
// TestLoadRejectsDuplicateAuthSourceTag pins the namespace-collision guard: two sources
// sharing a tag would collapse into one per-source UUID namespace, reopening cross-source
// impersonation. Must be rejected at load.
func TestLoadRejectsDuplicateAuthSourceTag(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "dup"
prefix = "AA"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "dup"
prefix = "BB"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
t.Fatal("expected error for duplicate auth_source tag")
}
if !strings.Contains(err.Error(), "unique") {
t.Errorf("error should explain the tags-must-be-unique contract, got: %v", err)
}
}
// TestLoadRejectsSchemelessAuthSourceURL pins the silently-dead-source guard: a URL with no
// http(s):// scheme makes http.NewRequest fail, so the source never validates any login yet
// felis-api boots green. Reject at load with the scheme contract spelled out. An empty tag
// is caught by the same loop.
func TestLoadRejectsSchemelessAuthSourceURL(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "bare"
prefix = "BR"
url = "bare.example.net/hasJoined"
`))
if err == nil {
t.Fatal("expected error for schemeless auth_source url")
}
if !strings.Contains(err.Error(), "scheme") {
t.Errorf("error should explain the scheme contract, got: %v", err)
}
}
// TestLoadNanoAcceptsMinimalConfig is the linchpin of the Felis-nano fold: a nano host has no
// Postgres and no FQDN, so LoadNano must accept a felis.toml carrying ONLY [[auth_source]] —
// the control-plane requirements (database.url, root_domain) that full Load enforces are
// deliberately skipped. It still applies the listen default and hands back the sources.
func TestLoadNanoAcceptsMinimalConfig(t *testing.T) {
cfg, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
prefix = "LS"
url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined"
`))
if err != nil {
t.Fatalf("LoadNano minimal: %v", err)
}
if len(cfg.AuthSources) != 1 || cfg.AuthSources[0].Tag != "littleskin" {
t.Fatalf("auth sources = %+v, want one littleskin source", cfg.AuthSources)
}
if cfg.Server.Listen != "0.0.0.0:8080" {
t.Errorf("default listen = %q, want 0.0.0.0:8080", cfg.Server.Listen)
}
}
// TestLoadNanoStillEnforcesAuthSourceRules pins that skipping the control-plane requirements
// does NOT skip the crown-jewel auth-source guard: a duplicate tag still collapses two sources
// into one UUID namespace, and LoadNano must reject it exactly as Load does (shared code path).
func TestLoadNanoStillEnforcesAuthSourceRules(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "dup"
prefix = "AA"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "dup"
prefix = "BB"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
t.Fatal("expected LoadNano to reject a duplicate auth_source tag")
}
if !strings.Contains(err.Error(), "unique") {
t.Errorf("error should explain the tags-must-be-unique contract, got: %v", err)
}
}
// TestLoadRejectsBadAuthSourcePrefix pins the rename-prefix contract. The prefix is prepended
// to a real Minecraft username (LS_steve) when a third-party player is holding a Mojang
// player's name, so it must exist and must be legal there — a missing or illegal prefix would
// otherwise only surface as a login the proxy silently refuses, months later, the first time
// two players collide.
func TestLoadRejectsBadAuthSourcePrefix(t *testing.T) {
for name, prefix := range map[string]string{
"missing": "",
"too long": "TOOLONG",
"underscore": "L_",
"non-ascii": "皮肤",
} {
t.Run(name, func(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
prefix = "`+prefix+`"
url = "https://littleskin.example.net/hasJoined"
`))
if err == nil {
t.Fatalf("expected error for prefix %q", prefix)
}
if !strings.Contains(err.Error(), "prefix") {
t.Errorf("error should name the prefix contract, got: %v", err)
}
})
}
}
// TestLoadRejectsDuplicateAuthSourcePrefix: two sources sharing a prefix rewrite their
// same-named players onto the SAME in-game name, which is the collision the prefix exists to
// break. Case-insensitively, because the proxy's player registry folds case.
func TestLoadRejectsDuplicateAuthSourcePrefix(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
prefix = "LS"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "otherskin"
prefix = "ls"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
t.Fatal("expected LoadNano to reject two sources sharing a prefix (case-insensitively)")
}
if !strings.Contains(err.Error(), "prefix") {
t.Errorf("error should name the prefix contract, got: %v", err)
}
}
func TestLoadRejectsUnknownKeys(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
typo_field = "oops"
[database]
url = "postgres://felis@db/felis"
`))
if err == nil {
t.Fatal("expected error for unknown key")
}
}