package instconf_test
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
)
// parse builds a File the way a daemon gets one, so the tests exercise the
// parser's own trimming rather than a map the test wrote by hand.
func parse(t *testing.T, text string) ini.File {
t.Helper()
conf, err := ini.Load(strings.NewReader(text))
require.NoError(t, err)
return conf
}
func TestCanonicalOriginTrimsTrailingSlashesAndSpace(t *testing.T) {
for name, tc := range map[string]struct{ in, want string }{
"empty stays empty": {"", ""},
"blank stays empty": {" ", ""},
"no slash untouched": {"https://x.example.org", "https://x.example.org"},
"one trailing slash": {"https://x.example.org/", "https://x.example.org"},
"several slashes": {"https://x.example.org///", "https://x.example.org"},
"trailing space": {"https://x.example.org ", "https://x.example.org"},
"leading space": {" https://x.example.org", "https://x.example.org"},
"space then slashes": {" https://x.example.org// ", "https://x.example.org"},
"port kept": {"https://x.example.org:8443/", "https://x.example.org:8443"},
"path prefix kept": {"https://example.org/git/", "https://example.org/git"},
"scheme-less untouched": {"x.example.org/", "x.example.org"},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.want, instconf.CanonicalOrigin(tc.in))
})
}
}
// The donors disagreed here: one canonicalized with strings.TrimSuffix, which
// leaves "https://x/" from "https://x//", and one with strings.TrimRight, which
// does not. This pins the strict reading, because the whole point is that the
// string compares equal to a browser's Origin header.
func TestCanonicalOriginBeatsTrimSuffix(t *testing.T) {
const doubled = "https://x.example.org//"
assert.Equal(t, "https://x.example.org/", strings.TrimSuffix(doubled, "/"),
"sanity: TrimSuffix is the behaviour being overruled")
assert.Equal(t, "https://x.example.org", instconf.CanonicalOrigin(doubled))
}
func TestExternalOriginReadsOriginOnly(t *testing.T) {
conf := parse(t, `
[git.sr.ht]
origin=https://git.example.org/
internal-origin=http://git.internal:5001
`)
assert.Equal(t, "https://git.example.org", instconf.ExternalOrigin(conf, "git.sr.ht"),
"internal-origin must never reach a browser")
}
func TestExternalOriginMissing(t *testing.T) {
conf := parse(t, `
[git.sr.ht]
internal-origin=http://git.internal:5001
[meta.sr.ht]
origin=
`)
assert.Empty(t, instconf.ExternalOrigin(conf, "git.sr.ht"), "key absent")
assert.Empty(t, instconf.ExternalOrigin(conf, "meta.sr.ht"), "key present but blank")
assert.Empty(t, instconf.ExternalOrigin(conf, "nope.sr.ht"), "section absent")
assert.Empty(t, instconf.ExternalOrigin(nil, "git.sr.ht"), "no config at all")
}
func TestInternalOriginPrefersInternal(t *testing.T) {
conf := parse(t, `
[git.sr.ht]
origin=https://git.example.org
internal-origin=http://git.internal:5001/
`)
assert.Equal(t, "http://git.internal:5001", instconf.InternalOrigin(conf, "git.sr.ht"))
}
func TestInternalOriginFallsBackToOrigin(t *testing.T) {
conf := parse(t, `
[git.sr.ht]
origin=https://git.example.org/
[meta.sr.ht]
origin=https://meta.example.org
internal-origin=
`)
assert.Equal(t, "https://git.example.org", instconf.InternalOrigin(conf, "git.sr.ht"),
"internal-origin absent")
assert.Equal(t, "https://meta.example.org", instconf.InternalOrigin(conf, "meta.sr.ht"),
"internal-origin present but blank must not shadow origin")
assert.Empty(t, instconf.InternalOrigin(conf, "nope.sr.ht"))
}
func TestInternalAPIOriginLadder(t *testing.T) {
full := `
[git.sr.ht]
api-internal-origin=http://git.internal:5101/
internal-origin=http://git.internal:5001
api-origin=https://api.git.example.org
origin=https://git.example.org
`
for name, tc := range map[string]struct {
drop []string
want string
}{
"api-internal-origin wins": {nil, "http://git.internal:5101"},
"then internal-origin": {
[]string{"api-internal-origin"}, "http://git.internal:5001"},
"then api-origin": {
[]string{"api-internal-origin", "internal-origin"}, "https://api.git.example.org"},
"then origin": {
[]string{"api-internal-origin", "internal-origin", "api-origin"},
"https://git.example.org"},
} {
t.Run(name, func(t *testing.T) {
text := full
for _, k := range tc.drop {
text = dropKey(t, text, k)
}
got, ok := instconf.InternalAPIOrigin(parse(t, text), "git.sr.ht")
require.True(t, ok)
assert.Equal(t, tc.want, got)
})
}
}
func TestInternalAPIOriginNotConfigured(t *testing.T) {
conf := parse(t, `
[git.sr.ht]
repos=/var/lib/git
[meta.sr.ht]
origin=
`)
got, ok := instconf.InternalAPIOrigin(conf, "git.sr.ht")
assert.False(t, ok, "no candidate key at all")
assert.Empty(t, got)
got, ok = instconf.InternalAPIOrigin(conf, "meta.sr.ht")
assert.False(t, ok, "a blank origin is not a configured API origin")
assert.Empty(t, got)
got, ok = instconf.InternalAPIOrigin(conf, "nope.sr.ht")
assert.False(t, ok, "section absent")
assert.Empty(t, got)
}
func TestAPIOriginKeysMatchesTheLadder(t *testing.T) {
want := []string{"api-internal-origin", "internal-origin", "api-origin", "origin"}
assert.Equal(t, want, instconf.APIOriginKeys())
keys := instconf.APIOriginKeys()
keys[0] = "clobbered"
assert.Equal(t, want, instconf.APIOriginKeys(), "the exported ladder must not be writable")
}
func TestOriginHostAndAuthority(t *testing.T) {
for name, tc := range map[string]struct{ in, host, authority string }{
"plain": {"https://git.example.org", "git.example.org", "git.example.org"},
"trailing slash": {"https://git.example.org/", "git.example.org", "git.example.org"},
"several slashes": {"https://git.example.org//", "git.example.org", "git.example.org"},
"surrounding space": {" https://git.example.org ", "git.example.org", "git.example.org"},
"with port": {"https://git.example.org:8443", "git.example.org", "git.example.org:8443"},
"path prefix": {"https://example.org/git", "example.org", "example.org"},
"ipv6 with port": {"http://[::1]:8080", "::1", "[::1]:8080"},
"scheme relative": {"//git.example.org", "git.example.org", "git.example.org"},
"case as configured": {"https://Git.Example.ORG", "Git.Example.ORG", "Git.Example.ORG"},
// Everything below has no host to report. "" is the answer in every
// case; a caller on a security path must not read it as "allow".
"empty": {"", "", ""},
"blank": {" ", "", ""},
"scheme-less": {"git.example.org", "", ""},
"bare word": {"not-a-url", "", ""},
"no scheme": {"://git.example.org", "", ""},
"space inside": {"https://git example.org", "", ""},
"bad escape": {"https://example.org/%zz", "", ""},
"unclosed ipv6": {"http://[::1", "", ""},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.host, instconf.OriginHost(tc.in), "OriginHost")
assert.Equal(t, tc.authority, instconf.OriginAuthority(tc.in), "OriginAuthority")
})
}
}
// The donor that answered "localhost" for an unparseable origin made every
// malformed config agree with a local client. This package refuses to guess.
func TestOriginHostNeverInventsLocalhost(t *testing.T) {
for _, bad := range []string{"", " ", "not-a-url", "://x", "http://[::1"} {
assert.Empty(t, instconf.OriginHost(bad), "origin %q", bad)
assert.Empty(t, instconf.OriginAuthority(bad), "origin %q", bad)
}
}
func TestRequireSatisfied(t *testing.T) {
conf := parse(t, `
[sr.ht]
network-key=abc
[webhooks]
private-key=def
[git.sr.ht]
internal-origin=http://git.internal:5001
[compare.sr.ht]
origin=https://compare.example.org
`)
err := instconf.Require(conf,
instconf.Need("sr.ht", "network-key"),
instconf.Need("webhooks", "private-key"),
instconf.Need("compare.sr.ht", "origin"),
instconf.NeedAny("git.sr.ht", instconf.APIOriginKeys()...),
)
assert.NoError(t, err)
assert.NoError(t, instconf.Require(conf), "no requirements is not a failure")
}
func TestRequireReportsEveryMissingKeyAtOnce(t *testing.T) {
conf := parse(t, `
[sr.ht]
network-key=abc
[meta.sr.ht]
origin=
[git.sr.ht]
repos=/var/lib/git
`)
err := instconf.Require(conf,
instconf.Need("sr.ht", "network-key"), // present
instconf.Need("webhooks", "private-key"), // section absent
instconf.Need("meta.sr.ht", "origin"), // present but blank
instconf.Need("compare.sr.ht", "origin"), // key absent
instconf.NeedAny("git.sr.ht", instconf.APIOriginKeys()...), // ladder empty
)
require.Error(t, err)
require.ErrorIs(t, err, instconf.ErrIncompleteConfig)
var missing *instconf.MissingKeysError
require.ErrorAs(t, err, &missing)
assert.Equal(t, []string{
"[webhooks] private-key",
"[meta.sr.ht] origin",
"[compare.sr.ht] origin",
"[git.sr.ht] one of api-internal-origin, internal-origin, api-origin, origin",
}, missing.Strings(), "every gap in one pass, in the order asked")
msg := err.Error()
assert.Contains(t, msg, "incomplete configuration")
assert.Contains(t, msg, "[compare.sr.ht] origin")
assert.NotContains(t, msg, "network-key", "a satisfied key must not be reported")
}
func TestRequireNamelessKeyIsUnsatisfiable(t *testing.T) {
conf := parse(t, "[sr.ht]\nnetwork-key=abc\n")
err := instconf.Require(conf, instconf.NeedAny("sr.ht"))
require.Error(t, err)
assert.Contains(t, err.Error(), "[sr.ht] <no key>")
}
// dropKey removes an "key=..." line from an ini fixture.
func dropKey(t *testing.T, text, key string) string {
t.Helper()
var kept []string
var dropped bool
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, key+"=") {
dropped = true
continue
}
kept = append(kept, line)
}
require.True(t, dropped, "fixture has no %q line", key)
return strings.Join(kept, "\n")
}
// The sentinel is what a daemon matches on to tell "not configured yet" apart
// from the failures that come after startup, so check it with errors.Is
// directly rather than only through testify.
func TestErrIncompleteConfigIsTheSentinel(t *testing.T) {
err := instconf.Require(nil, instconf.Need("sr.ht", "network-key"))
require.Error(t, err)
assert.True(t, errors.Is(err, instconf.ErrIncompleteConfig))
}