659 lines
22 KiB
Go
659 lines
22 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"
|
|
kaniko_image = "registry.felis.svc:5000/mirror/kaniko:v1.23.2"
|
|
trivy_image = "registry.felis.svc:5000/mirror/trivy:0.58.1"
|
|
build_cpu_limit = "1"
|
|
build_mem_limit = "2Gi"
|
|
|
|
[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)
|
|
}
|
|
if cfg.Registry.KanikoImage != "registry.felis.svc:5000/mirror/kaniko:v1.23.2" ||
|
|
cfg.Registry.TrivyImage != "registry.felis.svc:5000/mirror/trivy:0.58.1" {
|
|
t.Errorf("build image overrides = %q / %q", cfg.Registry.KanikoImage, cfg.Registry.TrivyImage)
|
|
}
|
|
if cfg.Registry.BuildCPULimit != "1" || cfg.Registry.BuildMemLimit != "2Gi" {
|
|
t.Errorf("build resource overrides = %q / %q", cfg.Registry.BuildCPULimit, cfg.Registry.BuildMemLimit)
|
|
}
|
|
}
|
|
|
|
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.
|
|
//
|
|
// The source is otherwise valid, so the only thing left to reject is the identity key itself:
|
|
// with a missing prefix the prefix rule would fail first and hide a loader that accepts it.
|
|
func TestLoadRejectsAuthSourceIdentityKey(t *testing.T) {
|
|
const source = `
|
|
[[auth_source]]
|
|
tag = "evil"
|
|
prefix = "EV"
|
|
url = "https://evil.example.net/hasJoined"
|
|
identity = true
|
|
`
|
|
_, errFull := config.Load(writeTOML(t, `
|
|
[server]
|
|
root_domain = "mc.example.net"
|
|
[database]
|
|
url = "postgres://felis@db/felis"
|
|
`+source))
|
|
_, errNano := config.LoadNano(writeTOML(t, source))
|
|
for loader, err := range map[string]error{"Load": errFull, "LoadNano": errNano} {
|
|
if err == nil || !strings.Contains(err.Error(), "unknown keys") || !strings.Contains(err.Error(), "identity") {
|
|
t.Errorf("%s: err = %v, want the identity key rejected as unknown", loader, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestLoadRejectsColonInAuthSourceTag pins the separator guard. The UUID of a third-party
|
|
// player is derived from tag+":"+nativeID, and the native id is chosen by the source, so with
|
|
// "guild" and "guild:eu" both configured the "guild" root could answer id "eu:X" and receive
|
|
// the UUID of "guild:eu"'s player X. Both loaders share the check, so both are exercised.
|
|
func TestLoadRejectsColonInAuthSourceTag(t *testing.T) {
|
|
const sources = `
|
|
[[auth_source]]
|
|
tag = "guild"
|
|
prefix = "GD"
|
|
url = "https://a.example.net/hasJoined"
|
|
[[auth_source]]
|
|
tag = "guild:eu"
|
|
prefix = "GE"
|
|
url = "https://b.example.net/hasJoined"
|
|
`
|
|
_, errFull := config.Load(writeTOML(t, `
|
|
[server]
|
|
root_domain = "mc.example.net"
|
|
[database]
|
|
url = "postgres://felis@db/felis"
|
|
`+sources))
|
|
_, errNano := config.LoadNano(writeTOML(t, sources))
|
|
for loader, err := range map[string]error{"Load": errFull, "LoadNano": errNano} {
|
|
if err == nil {
|
|
t.Errorf("%s accepted a tag containing ':'", loader)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), `"guild:eu"`) || !strings.Contains(err.Error(), "':'") {
|
|
t.Errorf("%s: error should name the tag and the ':' rule, got: %v", loader, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLoadRejectsPaddedAuthSourceTag: whitespace around a tag cannot be seen in the file but
|
|
// is part of the namespace every player UUID of the source is hashed from.
|
|
func TestLoadRejectsPaddedAuthSourceTag(t *testing.T) {
|
|
for _, tag := range []string{"littleskin ", " littleskin", "littleskin\t"} {
|
|
_, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \""+tag+"\"\nprefix = \"LS\"\nurl = \"https://a.example.net/hasJoined\"\n"))
|
|
if err == nil || !strings.Contains(err.Error(), "whitespace") {
|
|
t.Errorf("tag %q: err = %v, want a whitespace refusal", tag, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLoadRejectsMojangAuthSourceTag: Mojang is prepended in code, so a listed "mojang" is a
|
|
// second, different source that only looks like a Mojang override.
|
|
func TestLoadRejectsMojangAuthSourceTag(t *testing.T) {
|
|
for _, tag := range []string{"mojang", "Mojang"} {
|
|
_, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \""+tag+"\"\nprefix = \"MJ\"\nurl = \"https://sessionserver.mojang.com/session/minecraft/hasJoined\"\n"))
|
|
if err == nil || !strings.Contains(err.Error(), "built in") {
|
|
t.Errorf("tag %q: err = %v, want a refusal saying Mojang is built in", tag, 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)
|
|
}
|
|
}
|
|
|
|
// TestLoadRejectsUnqueryableAuthSourceURL covers the URL shapes that carry a scheme yet can
|
|
// never be queried: the resolver appends the query string to the URL verbatim, so each of
|
|
// these would load green and leave a source that silently validates nobody.
|
|
func TestLoadRejectsUnqueryableAuthSourceURL(t *testing.T) {
|
|
for _, u := range []string{
|
|
"ftp://a.example.net/hasJoined",
|
|
"https://",
|
|
"https://a.example.net/hasJoined?token=x",
|
|
"https://a.example.net/hasJoined?",
|
|
"https://a.example.net/hasJoined#x",
|
|
"https://a.example.net/hasJoined ",
|
|
"https://a.example.net:bad/hasJoined",
|
|
} {
|
|
_, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \"a\"\nprefix = \"AA\"\nurl = \""+u+"\"\n"))
|
|
if err == nil {
|
|
t.Errorf("url %q loaded; it can never be queried", u)
|
|
}
|
|
}
|
|
if _, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \"a\"\nprefix = \"AA\"\nurl = \"http://127.0.0.1:8080/hasJoined\"\n")); err != nil {
|
|
t.Errorf("a plain loopback endpoint must load: %v", err)
|
|
}
|
|
}
|
|
|
|
// A source reached over plaintext can be answered by anyone on the path, who can then log in
|
|
// as any player of that source. Only a same-host or private-network root may skip TLS, and
|
|
// that is decided on the literal host, since nothing is resolved at load time.
|
|
func TestLoadRejectsPlaintextPublicAuthSource(t *testing.T) {
|
|
load := func(u string) error {
|
|
_, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \"a\"\nprefix = \"AA\"\nurl = \""+u+"\"\n"))
|
|
return err
|
|
}
|
|
for _, host := range []string{"ygg.example.net", "203.0.113.9", "ygg.lan", "172.32.0.1", "169.254.1.1", "0.0.0.0", "[2001:db8::1]"} {
|
|
u := "http://" + host + "/hasJoined"
|
|
if err := load(u); err == nil || !strings.Contains(err.Error(), "https://") {
|
|
t.Errorf("url %q: err = %v, want a refusal that asks for https://", u, err)
|
|
}
|
|
if err := load("https://" + host + "/hasJoined"); err != nil {
|
|
t.Errorf("the same host over https must load: %v", err)
|
|
}
|
|
}
|
|
for _, host := range []string{"localhost", "LOCALHOST:8080", "127.0.0.1:8080", "127.1.2.3", "[::1]:8080", "10.0.0.5", "172.16.3.4", "192.168.1.2", "[fd00::1]"} {
|
|
if err := load("http://" + host + "/hasJoined"); err != nil {
|
|
t.Errorf("a plaintext root on %s must load: %v", host, 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 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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// TestLoadSMTPDefaultsPort pins the [smtp] contract: a host with no port gets the
|
|
// 587 STARTTLS default, and an absent [smtp] block stays fully zero (no mailer).
|
|
func TestLoadSMTPDefaultsPort(t *testing.T) {
|
|
cfg, err := config.Load(writeTOML(t, `
|
|
[server]
|
|
root_domain = "mc.example.net"
|
|
[database]
|
|
url = "postgres://felis@db/felis"
|
|
[smtp]
|
|
host = "smtp.example.net"
|
|
from = "[email protected]"
|
|
`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.SMTP.Port != 587 {
|
|
t.Errorf("smtp port = %d, want the 587 default", cfg.SMTP.Port)
|
|
}
|
|
|
|
cfg, err = config.Load(writeTOML(t, `
|
|
[server]
|
|
root_domain = "mc.example.net"
|
|
[database]
|
|
url = "postgres://felis@db/felis"
|
|
`))
|
|
if err != nil {
|
|
t.Fatalf("Load without [smtp]: %v", err)
|
|
}
|
|
if cfg.SMTP.Host != "" || cfg.SMTP.Port != 0 {
|
|
t.Errorf("absent [smtp] must stay zero, got %+v", cfg.SMTP)
|
|
}
|
|
}
|
|
|
|
// TestLoadRejectsSMTPWithoutFrom guards the deliverability rule: naming a relay
|
|
// host commits the block to being sendable, so a missing/invalid From fails at
|
|
// load rather than at the first OTP a player is waiting on.
|
|
func TestLoadRejectsSMTPWithoutFrom(t *testing.T) {
|
|
_, err := config.Load(writeTOML(t, `
|
|
[server]
|
|
root_domain = "mc.example.net"
|
|
[database]
|
|
url = "postgres://felis@db/felis"
|
|
[smtp]
|
|
host = "smtp.example.net"
|
|
`))
|
|
if err == nil {
|
|
t.Fatal("expected error when [smtp] host is set without a from address")
|
|
}
|
|
}
|
|
|
|
// TestClientIPHeaderAndMailCap pins the two knobs behind the sign-in rate
|
|
// limits: the visitor-address header (explicit, or implied by an Access
|
|
// audience that only the Cloudflare edge setup writes) and the mail cap.
|
|
func TestClientIPHeaderAndMailCap(t *testing.T) {
|
|
base := `
|
|
[server]
|
|
root_domain = "mc.example.net"
|
|
[database]
|
|
url = "postgres://felis@db/felis"
|
|
`
|
|
for _, tc := range []struct {
|
|
name, auth, want string
|
|
}{
|
|
{"unset", "", ""},
|
|
{"cloudflare audience implies CF-Connecting-IP", "access_jwt_aud = \"aud123\"\n", "CF-Connecting-IP"},
|
|
{"explicit wins", "access_jwt_aud = \"aud123\"\nclient_ip_header = \"X-Forwarded-For\"\n", "X-Forwarded-For"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg, err := config.Load(writeTOML(t, base+"[auth]\n"+tc.auth))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if got := cfg.Auth.EffectiveClientIPHeader(); got != tc.want {
|
|
t.Fatalf("EffectiveClientIPHeader = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
if _, err := config.Load(writeTOML(t, base+"[auth]\nclient_ip_header = \"CF-Connecting-IP: 1.2.3.4\"\n")); err == nil {
|
|
t.Fatal("a header line with a value was accepted as a header name")
|
|
}
|
|
if _, err := config.Load(writeTOML(t, base+"[smtp]\nhost = \"smtp.example.net\"\nfrom = \"[email protected]\"\nmax_per_hour = -1\n")); err == nil {
|
|
t.Fatal("negative max_per_hour accepted")
|
|
}
|
|
}
|
|
|
|
// TestLoadOffsite: a bucket turns the table on and fills in the secret refs and
|
|
// bundle count; a half-filled or malformed table fails at load.
|
|
func TestLoadOffsite(t *testing.T) {
|
|
const head = `
|
|
[server]
|
|
root_domain = "mc.example.net"
|
|
[database]
|
|
url = "postgres://felis@db/felis"
|
|
`
|
|
cfg, err := config.Load(writeTOML(t, head))
|
|
if err != nil {
|
|
t.Fatalf("Load without [offsite]: %v", err)
|
|
}
|
|
if cfg.Offsite.Enabled() || cfg.Offsite.KeyRef != "" {
|
|
t.Fatalf("absent [offsite] must stay off and zero, got %+v", cfg.Offsite)
|
|
}
|
|
|
|
cfg, err = config.Load(writeTOML(t, head+`
|
|
[offsite]
|
|
endpoint = "https://s3.example.net"
|
|
bucket = "felis-copies"
|
|
prefix = "site-a"
|
|
`))
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
o := cfg.Offsite
|
|
if !o.Enabled() || o.AccessKeyRef != "FELIS_OFFSITE_ACCESS_KEY" || o.SecretKeyRef != "FELIS_OFFSITE_SECRET_KEY" ||
|
|
o.KeyRef != "FELIS_OFFSITE_KEY" || o.DBKeep != 30 {
|
|
t.Fatalf("defaults not applied: %+v", o)
|
|
}
|
|
|
|
for name, body := range map[string]string{
|
|
"no bucket": "endpoint = \"https://s3.example.net\"",
|
|
"no endpoint": "bucket = \"b\"",
|
|
"path in endpoint": "endpoint = \"https://s3.example.net/b\"\nbucket = \"b\"",
|
|
"ftp endpoint": "endpoint = \"ftp://s3.example.net\"\nbucket = \"b\"",
|
|
"bucket with key": "endpoint = \"s3.example.net\"\nbucket = \"b/x\"",
|
|
"negative keep": "endpoint = \"s3.example.net\"\nbucket = \"b\"\ndb_keep = -1",
|
|
"secret in toml": "endpoint = \"s3.example.net\"\nbucket = \"b\"\nsecret_key = \"x\"",
|
|
} {
|
|
if _, err := config.Load(writeTOML(t, head+"[offsite]\n"+body+"\n")); err == nil {
|
|
t.Errorf("%s: loaded", name)
|
|
}
|
|
}
|
|
}
|