Files
Felis/cmd/felis/nano_test.go
T
flyemoji 59ec23d4a8 test(nano): cover the nano delivery path and its loopback default
felis nano serves the same hasJoined handler as felis api, but behind
nanoStubRepo, which implements only the bar-list lookup and embeds a nil
Repo for everything else. Only the full-api path was tested, against a
complete fake store, so a second store call added to handleHasJoined
would pass CI and panic on every nano login. The loopback default of
-listen, the one thing keeping nano from being an open auth relay, was
not pinned either.

The default moves into a nanoDefaultListen constant, and two tests
cover the path. One serves a login through api.HasJoinedHandler with
nanoStubRepo and a fake identity source and expects the profile back.
The other requires the default to parse as a loopback IP. Taking the
bar-list method off the stub makes the first panic on the nil Repo;
defaulting to 0.0.0.0:8081 or :8081 fails the second.
2026-09-22 13:37:51 +09:00

140 lines
5.1 KiB
Go

package main
import (
"bytes"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"unicode/utf8"
"felis.lolicon.best/internal/api"
)
// [server] listen in a nano config reads like the bind address but is not one; nano must
// say so. The -listen value cannot be bound, so cmdNano returns right after loading.
func TestNanoWarnsThatServerListenIsIgnored(t *testing.T) {
cfg := filepath.Join(t.TempDir(), "felis.toml")
if err := os.WriteFile(cfg, []byte("[server]\nlisten = \"0.0.0.0:9999\"\n"), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if rc := cmdNano([]string{"-config", cfg, "-listen", "127.0.0.1:-1"}, io.Discard, &stderr); rc != 1 {
t.Fatalf("cmdNano = %d, want 1 from the unbindable -listen", rc)
}
if !strings.Contains(stderr.String(), `listen = "0.0.0.0:9999" is ignored`) {
t.Fatalf("stderr %q should say the configured listen is ignored", stderr.String())
}
// With no [server] table at all there is nothing to warn about.
if err := os.WriteFile(cfg, nil, 0o600); err != nil {
t.Fatal(err)
}
stderr.Reset()
_ = cmdNano([]string{"-config", cfg, "-listen", "127.0.0.1:-1"}, io.Discard, &stderr)
if strings.Contains(stderr.String(), "is ignored") {
t.Fatalf("stderr %q warns about a listen the operator never set", stderr.String())
}
}
// A stop signal that lands while a login is waiting on an upstream must let that login
// finish: the request is answered, and serveNano returns only afterwards.
func TestNanoDrainsInFlightLoginOnShutdown(t *testing.T) {
entered, release := make(chan struct{}), make(chan struct{})
srv := newAPIServer("", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(entered)
<-release
w.WriteHeader(http.StatusNoContent)
}))
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, stop := context.WithCancel(context.Background())
done := make(chan int, 1)
go func() { done <- serveNano(ctx, srv, ln, io.Discard) }()
got := make(chan int, 1)
go func() {
resp, err := http.Get("http://" + ln.Addr().String() + "/session/minecraft/hasJoined")
if err != nil {
got <- -1
return
}
resp.Body.Close()
got <- resp.StatusCode
}()
<-entered
stop()
select {
case <-done:
t.Fatal("serveNano returned while a login was still in flight")
case <-time.After(200 * time.Millisecond):
}
close(release)
if code := <-got; code != http.StatusNoContent {
t.Fatalf("in-flight login got %d, want its answer (204)", code)
}
if rc := <-done; rc != 0 {
t.Fatalf("serveNano = %d after a clean drain, want 0", rc)
}
}
// The nano delivery path: the shared handler behind nano's stub store must admit a login its
// source validated. nanoStubRepo implements only the bar-list lookup, so a new store call in
// handleHasJoined would reach its nil embedded Repo and panic here, while the full-api tests,
// which use a complete fake store, stay green.
func TestNanoAdmitsAValidatedLogin(t *testing.T) {
const id = "069a79f444e94726a5befca90e38aaf5"
ygg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"id":"`+id+`","name":"Notch"}`)
}))
defer ygg.Close()
h := api.HasJoinedHandler([]api.AuthSource{{Tag: "mojang", URL: ygg.URL, Identity: true}}, nanoStubRepo{})
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/session/minecraft/hasJoined?username=Notch&serverId=abc", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), id) {
t.Fatalf("code = %d body = %q, want the validated profile", w.Code, w.Body.String())
}
}
// An unauthenticated relay on a public address spends this host's Mojang rate limit for
// anyone who finds it, so the default bind has to stay loopback.
func TestNanoListensOnLoopbackByDefault(t *testing.T) {
host, _, err := net.SplitHostPort(nanoDefaultListen)
if ip := net.ParseIP(host); err != nil || ip == nil || !ip.IsLoopback() {
t.Fatalf("default -listen %q is not a loopback address", nanoDefaultListen)
}
}
// The request log prints text the caller chose. A bidi override must not reorder the line,
// an invalid byte must not make journald store the entry as a blob, and a huge query must
// not become a huge log line. serverId is left out so the handler answers without asking
// any source.
func TestNanoRequestLogIsQuotedAndCapped(t *testing.T) {
const rlo = rune(0x202e) // RIGHT-TO-LEFT OVERRIDE
var log bytes.Buffer
h := nanoHandler(nil, &log)
target := "/session/minecraft/hasJoined?username=" + string(rlo) + "evil" + string([]byte{0x9b}) + "31m" + strings.Repeat("a", 4096)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
line := log.String()
if strings.ContainsRune(line, rlo) || !utf8.ValidString(line) {
t.Fatalf("raw caller bytes reached the log: %q", line)
}
if escaped := strings.Trim(strconv.QuoteRune(rlo), "'"); !strings.Contains(line, escaped) {
t.Fatalf("log line %q should show the override escaped as %s", line, escaped)
}
if len(line) > 2*nanoLogURIMax {
t.Fatalf("log line is %d bytes for a %d-byte URI; want it capped", len(line), len(target))
}
}