Files
Felis/internal/api/handlers_auth_sources_test.go
T
Lemon-miaow 61b283ae2f feat(auth): add Owner authentication source settings
Manage Yggdrasil providers from the panel using durable platform settings, protected identity namespaces and atomic revisions. Apply changes to subsequent logins and profile lookups without restarting. Return operator-host logouts to the login method selection page.
2026-10-04 22:18:37 +08:00

287 lines
11 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"felis.lolicon.best/internal/config"
)
const authSourcesPath = "/api/v1/settings/auth-sources"
func sourceEntry(tag, prefix, endpoint string) authSourceEntry {
return authSourceEntry{AuthSourceConfig: config.AuthSourceConfig{Tag: tag, Prefix: prefix, URL: endpoint}, Enabled: true}
}
func seedAuthSourcesAPI() (*API, *fakeRepo) {
repo := newFakeRepo()
a := newTestAPI(repo, newFakeCluster())
a.External = staticExternal{p: &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}}
a.AuthSources = []AuthSource{{Tag: "mojang", URL: "https://sessionserver.mojang.com/session/minecraft/hasJoined", Identity: true}, {Tag: "littleskin", Prefix: "LS", URL: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined"}}
a.AuthSourceSettings = &AuthSourceSettings{Repo: repo, Defaults: a.AuthSources}
return a, repo
}
func readAuthSources(t *testing.T, a *API) authSourcesView {
t.Helper()
w := do(a.ExternalHandler(), "GET", authSourcesPath, "", nil)
if w.Code != 200 {
t.Fatalf("read = %d %s", w.Code, w.Body.String())
}
var view authSourcesView
if err := json.Unmarshal(w.Body.Bytes(), &view); err != nil {
t.Fatal(err)
}
return view
}
func saveAuthSources(a *API, view authSourcesView) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"sources": view.Sources, "revision": view.Revision})
return do(a.ExternalHandler(), "PUT", authSourcesPath, string(body), jsonHeader)
}
func TestAuthSourcesSaveAndRuntime(t *testing.T) {
const native = "123456781234423482341234567890ab"
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/official" {
w.WriteHeader(204)
return
}
if strings.HasSuffix(r.URL.Path, "/profile/"+native) {
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
return
}
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
}))
defer upstream.Close()
a, repo := seedAuthSourcesAPI()
a.AuthSources[0].URL = upstream.URL + "/official"
a.AuthSources[1].URL = upstream.URL + "/sessionserver/session/minecraft/hasJoined"
before := readAuthSources(t, a)
if before.Managed || len(before.Sources) != 1 || before.Sources[0].Tag != "littleskin" {
t.Fatalf("defaults = %+v", before)
}
before.Sources = append(before.Sources, sourceEntry("other", "OT", upstream.URL+"/other-check"))
before.Sources[1].APIURL = upstream.URL
before.Sources[0], before.Sources[1] = before.Sources[1], before.Sources[0]
w := saveAuthSources(a, before)
if w.Code != 200 {
t.Fatalf("save = %d %s", w.Code, w.Body.String())
}
saved := readAuthSources(t, a)
if !saved.Managed || saved.Revision == before.Revision || saved.Sources[0].Tag != "other" {
t.Fatalf("saved = %+v", saved)
}
// Another API replica sees the same order and identity policy without restart.
replica := newTestAPI(repo, newFakeCluster())
replica.AuthSourceSettings = &AuthSourceSettings{Repo: repo, Defaults: a.AuthSources}
sources, err := replica.currentAuthSources(context.Background())
if err != nil || len(sources) != 3 || !sources[0].Identity || sources[1].Identity || sources[1].Tag != "other" {
t.Fatalf("active = %+v err = %v", sources, err)
}
profile, src, failed := replica.resolveHasJoined(context.Background(), "LemonMiaow", "joined-session", "")
if profile == nil || failed || src.Tag != "other" {
t.Fatalf("login = %+v src=%+v failed=%v", profile, src, failed)
}
preview, err := a.lookupProfile(context.Background(), "other", native)
if err != nil {
t.Fatal(err)
}
canonical, _ := canonicalProfileUUID(src, native)
if preview.MCUUID != canonical.String() {
t.Fatal("role lookup and game identity disagree")
}
// Disabled sources disappear from login priority and role lookup immediately.
saved.Sources[0].Enabled = false
if w = saveAuthSources(a, saved); w.Code != 200 {
t.Fatalf("disable = %d %s", w.Code, w.Body.String())
}
_, src, _ = replica.resolveHasJoined(context.Background(), "LemonMiaow", "joined-session", "")
if src.Tag != "littleskin" {
t.Fatalf("disabled source still used: %+v", src)
}
if _, err = a.lookupProfile(context.Background(), "other", native); err == nil {
t.Fatal("disabled source remains selectable")
}
list := do(a.ExternalHandler(), "GET", "/api/v1/account/link/sources", "", nil)
if list.Code != 200 || strings.Contains(list.Body.String(), `"other"`) || strings.Contains(list.Body.String(), upstream.URL) {
t.Fatalf("public source list = %d %s", list.Code, list.Body.String())
}
// Durable failure must never resurrect the installation defaults.
repo.failGetSetting = errors.New("database unavailable")
profile, _, failed = replica.resolveHasJoined(context.Background(), "LemonMiaow", "joined-session", "")
if profile != nil || !failed {
t.Fatal("DB outage fell back to obsolete configuration")
}
if _, err = replica.currentAuthSources(context.Background()); err == nil {
t.Fatal("settings outage ignored")
}
}
func TestAuthSourcesProtectNamespacesAndRevision(t *testing.T) {
a, repo := seedAuthSourcesAPI()
initial := readAuthSources(t, a)
for _, rename := range []bool{false, true} {
v := readAuthSources(t, a)
if rename {
v.Sources[0].Tag = "renamed"
} else {
v.Sources = []authSourceEntry{}
}
w := saveAuthSources(a, v)
if w.Code != 409 || decodeErr(t, w) != "auth_source_tag_locked" {
t.Fatalf("namespace change = %d %s", w.Code, w.Body.String())
}
}
initial.Sources[0].Enabled = false
if w := saveAuthSources(a, initial); w.Code != 200 {
t.Fatalf("save = %d %s", w.Code, w.Body.String())
}
original := string(repo.settings[authSourcesKey])
initial.Sources[0].Prefix = "XX"
if w := saveAuthSources(a, initial); w.Code != 409 || decodeErr(t, w) != "auth_sources_changed" {
t.Fatalf("stale save = %d %s", w.Code, w.Body.String())
}
if string(repo.settings[authSourcesKey]) != original {
t.Fatal("stale save overwrote durable config")
}
// Invalid stored data also fails closed.
repo.settings[authSourcesKey] = []byte(`[{"tag":"mojang","prefix":"M","url":"https://example.test/check","enabled":true}]`)
if _, err := a.currentAuthSources(context.Background()); err == nil {
t.Fatal("corrupt DB config trusted as Mojang")
}
}
type conflictSettingsRepo struct{ *fakeRepo }
func (r conflictSettingsRepo) CompareAndSetSetting(context.Context, string, []byte, []byte) error {
return ErrConflict
}
func TestAuthSourcesConcurrentWrite(t *testing.T) {
a, repo := seedAuthSourcesAPI()
v := readAuthSources(t, a)
a.AuthSourceSettings.Repo = conflictSettingsRepo{repo}
v.Sources[0].Enabled = false
w := saveAuthSources(a, v)
if w.Code != 409 || decodeErr(t, w) != "auth_sources_changed" || len(repo.settings) != 0 {
t.Fatalf("concurrent write = %d %s", w.Code, w.Body.String())
}
}
func TestAuthSourcesValidation(t *testing.T) {
valid := sourceEntry("custom", "CS", "https://example.test/check")
for _, tc := range []struct {
name string
entries []authSourceEntry
}{
{"null", nil},
{"Mojang", []authSourceEntry{sourceEntry("MOJANG", "M", valid.URL)}},
{"empty tag", []authSourceEntry{sourceEntry("", "M", valid.URL)}},
{"colon", []authSourceEntry{sourceEntry("x:y", "M", valid.URL)}},
{"duplicate tag", []authSourceEntry{valid, valid}},
{"duplicate prefix", []authSourceEntry{valid, sourceEntry("other", "cs", valid.URL)}},
{"long prefix", []authSourceEntry{sourceEntry("x", "ABCDE", valid.URL)}},
{"public HTTP", []authSourceEntry{sourceEntry("x", "X", "http://example.test/check")}},
{"query", []authSourceEntry{sourceEntry("x", "X", valid.URL+"?secret=1")}},
{"fragment", []authSourceEntry{sourceEntry("x", "X", valid.URL+"#fragment")}},
} {
t.Run(tc.name, func(t *testing.T) {
a, repo := seedAuthSourcesAPI()
v := readAuthSources(t, a)
v.Sources = tc.entries
w := saveAuthSources(a, v)
if w.Code != 400 || len(repo.settings) != 0 {
t.Fatalf("validation = %d %s", w.Code, w.Body.String())
}
})
}
a, _ := seedAuthSourcesAPI()
w := do(a.ExternalHandler(), "PUT", authSourcesPath, `{"sources":[],"revision":"x","identity":true}`, jsonHeader)
if w.Code != 400 {
t.Fatal("caller could set trust policy")
}
w = do(a.ExternalHandler(), "PUT", authSourcesPath, `{}`, map[string]string{"Content-Type": "application/x-www-form-urlencoded"})
if w.Code != 415 {
t.Fatal("cross-site form accepted")
}
}
func TestAuthSourcesOwnerGateAndReauth(t *testing.T) {
for _, p := range []*Principal{nil, {UserID: "admin", Role: "admin", ViaAdminAccess: true}, {UserID: "user", Role: "user", ViaAdminAccess: true}, {UserID: "owner", Role: "owner"}} {
a, repo := seedAuthSourcesAPI()
a.External = staticExternal{p: p}
for _, route := range []struct{ method, path string }{{"GET", authSourcesPath}, {"PUT", authSourcesPath}, {"POST", authSourcesPath + "/test"}} {
w := do(a.ExternalHandler(), route.method, route.path, `{}`, jsonHeader)
if w.Code != 401 && w.Code != 403 {
t.Fatalf("%+v reached %s: %d", p, route.path, w.Code)
}
}
if len(repo.settings) != 0 {
t.Fatal("unauthorized write")
}
}
a, repo := seedAuthSourcesAPI()
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true, ViaSession: true, EmailVerified: true}
repo.passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: p.UserID, UserVerified: true}
a.External = staticExternal{p: p}
v := readAuthSources(t, a)
v.Sources[0].Enabled = false
w := saveAuthSources(a, v)
if w.Code != 403 || decodeErr(t, w) != "reauth_required" || len(repo.settings) != 0 {
t.Fatalf("reauth = %d %s", w.Code, w.Body.String())
}
p.ReauthAt = a.now()
if w = saveAuthSources(a, v); w.Code != 200 {
t.Fatalf("fresh reauth = %d %s", w.Code, w.Body.String())
}
}
func TestAuthSourceProbe(t *testing.T) {
for _, status := range []int{204, 200, 302, 503} {
t.Run(http.StatusText(status), func(t *testing.T) {
ids := []string{}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("username") != "FelisProbe" {
t.Error("missing probe user")
}
ids = append(ids, r.URL.Query().Get("serverId"))
w.Header().Set("Location", "http://127.0.0.1:1/no-redirect")
w.WriteHeader(status)
}))
defer upstream.Close()
a, repo := seedAuthSourcesAPI()
body, _ := json.Marshal(sourceEntry("probe", "P", upstream.URL))
for i := 0; i < 2; i++ {
w := do(a.ExternalHandler(), "POST", authSourcesPath+"/test", string(body), jsonHeader)
var result struct {
OK bool `json:"ok"`
Status int `json:"status"`
Elapsed int64 `json:"elapsed_ms"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if w.Code != 200 || result.OK != (status == 204) || result.Status != status || result.Elapsed < 0 {
t.Fatalf("probe = %d %s", w.Code, w.Body.String())
}
}
if len(ids) != 2 || ids[0] == "" || ids[0] == ids[1] || !reflect.DeepEqual(repo.settings, map[string][]byte{}) {
t.Fatal("probe reused identity or saved config")
}
})
}
a, _ := seedAuthSourcesAPI()
body, _ := json.Marshal(sourceEntry("probe", "P", "http://127.0.0.1:1/check"))
w := do(a.ExternalHandler(), "POST", authSourcesPath+"/test", string(body), jsonHeader)
if w.Code != 503 || decodeErr(t, w) != "auth_source_unavailable" {
t.Fatalf("unreachable = %d %s", w.Code, w.Body.String())
}
}