mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:17:53 +08:00
981 lines
31 KiB
Go
981 lines
31 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"bat-api/internal/backendrpc"
|
|
)
|
|
|
|
func fixtureRoot(t *testing.T) string {
|
|
t.Helper()
|
|
root := filepath.Join("testdata", "release")
|
|
abs, err := filepath.Abs(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(abs, "official-download-manifest.json")); err != nil {
|
|
t.Fatalf("fixture missing: %v", err)
|
|
}
|
|
return abs
|
|
}
|
|
|
|
func TestLoadIndexFromResourceRoot(t *testing.T) {
|
|
idx, err := LoadIndexFromResourceRoot(fixtureRoot(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(idx.Entries) != 2 {
|
|
t.Fatalf("entries = %d", len(idx.Entries))
|
|
}
|
|
sum := idx.Summary()
|
|
if !sum.Ready || sum.PresentCount != 2 {
|
|
t.Fatalf("summary = %+v", sum)
|
|
}
|
|
if sum.Snapshot == nil {
|
|
t.Fatal("snapshot missing")
|
|
}
|
|
if sum.Snapshot.LauncherMetadata == nil {
|
|
t.Fatal("launcher metadata missing")
|
|
}
|
|
if sum.Snapshot.LauncherMetadata.GameLatestVersion != "1.70.0" {
|
|
t.Fatalf("launcher game version=%q", sum.Snapshot.LauncherMetadata.GameLatestVersion)
|
|
}
|
|
if got := sum.Snapshot.LauncherMetadata.GameStartParams; len(got) != 1 || got[0] != "BlueArchive.exe" {
|
|
t.Fatalf("launcher params=%v", got)
|
|
}
|
|
if sum.Snapshot.GameMainConfig == nil || sum.Snapshot.GameMainConfig.DefaultConnectionGroup != "Prod" {
|
|
t.Fatalf("game main config=%+v", sum.Snapshot.GameMainConfig)
|
|
}
|
|
rel := "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
|
|
entry, ok := idx.Lookup(rel)
|
|
if !ok || !entry.Present {
|
|
t.Fatalf("lookup failed: %+v", entry)
|
|
}
|
|
}
|
|
|
|
func TestSplitCDNPathRejectsEscape(t *testing.T) {
|
|
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if _, _, err := SplitCDNPath("/evil.example/a"); err == nil {
|
|
t.Fatal("expected unsupported host")
|
|
}
|
|
host, rel, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes")
|
|
if err != nil || host != ClientPatchHost {
|
|
t.Fatalf("host=%s rel=%s err=%v", host, rel, err)
|
|
}
|
|
}
|
|
|
|
func TestCDNServesIndexedFile(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
idx, err := LoadIndexFromResourceRoot(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
cfg.PublicBaseURL = "http://127.0.0.1:18080"
|
|
_ = cfg.Normalize()
|
|
s := NewServer(cfg, nil, nil)
|
|
s.mu.Lock()
|
|
s.idx = idx
|
|
s.meta = DiscoverResult{ResourceRoot: root, Index: idx}
|
|
s.mu.Unlock()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes", nil)
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Body.String() != "TABLE_CATALOG_FIXTURE" {
|
|
t.Fatalf("body=%q", rr.Body.String())
|
|
}
|
|
if rr.Header().Get("Accept-Ranges") != "bytes" {
|
|
t.Fatalf("Accept-Ranges=%q", rr.Header().Get("Accept-Ranges"))
|
|
}
|
|
if rr.Header().Get("ETag") == "" {
|
|
t.Fatal("ETag missing")
|
|
}
|
|
if rr.Header().Get("Last-Modified") == "" {
|
|
t.Fatal("Last-Modified missing")
|
|
}
|
|
if rr.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
|
|
t.Fatalf("Cache-Control=%q", rr.Header().Get("Cache-Control"))
|
|
}
|
|
|
|
// Unknown path
|
|
req = httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/missing.bin", nil)
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("missing status=%d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestCDNSupportsRangeHeadAndConditionalRequests(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
idx, err := LoadIndexFromResourceRoot(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
_ = cfg.Normalize()
|
|
s := NewServer(cfg, nil, nil)
|
|
s.mu.Lock()
|
|
s.idx = idx
|
|
s.meta = DiscoverResult{ResourceRoot: root, Index: idx}
|
|
s.mu.Unlock()
|
|
|
|
path := "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("Range", "bytes=0-4")
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusPartialContent {
|
|
t.Fatalf("range status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Body.String() != "TABLE" {
|
|
t.Fatalf("range body=%q", rr.Body.String())
|
|
}
|
|
if rr.Header().Get("Content-Range") != "bytes 0-4/21" {
|
|
t.Fatalf("Content-Range=%q", rr.Header().Get("Content-Range"))
|
|
}
|
|
etag := rr.Header().Get("ETag")
|
|
if etag != `"blake3-0000000000000000000000000000000000000000000000000000000000000000"` {
|
|
t.Fatalf("ETag=%q", etag)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodHead, path, nil)
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("head status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Body.Len() != 0 {
|
|
t.Fatalf("head body=%q", rr.Body.String())
|
|
}
|
|
if rr.Header().Get("Content-Length") != "21" {
|
|
t.Fatalf("head Content-Length=%q", rr.Header().Get("Content-Length"))
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("If-None-Match", etag)
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusNotModified {
|
|
t.Fatalf("conditional status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("Range", "bytes=99-120")
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusRequestedRangeNotSatisfiable {
|
|
t.Fatalf("invalid range status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCDNHashContentType(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
idx, err := LoadIndexFromResourceRoot(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
_ = cfg.Normalize()
|
|
s := NewServer(cfg, nil, nil)
|
|
s.mu.Lock()
|
|
s.idx = idx
|
|
s.meta = DiscoverResult{ResourceRoot: root, Index: idx}
|
|
s.mu.Unlock()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash", nil)
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Header().Get("Content-Type") != "text/plain; charset=utf-8" {
|
|
t.Fatalf("Content-Type=%q", rr.Header().Get("Content-Type"))
|
|
}
|
|
}
|
|
|
|
func TestHealthzAndRelease(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
idx, err := LoadIndexFromResourceRoot(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
_ = cfg.Normalize()
|
|
s := NewServer(cfg, nil, nil)
|
|
s.mu.Lock()
|
|
s.idx = idx
|
|
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
|
|
s.mu.Unlock()
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if rr.Code != 200 {
|
|
t.Fatal(rr.Body.String())
|
|
}
|
|
var health map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if health["ready"] != true {
|
|
t.Fatalf("health=%v", health)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/resources?limit=1", nil))
|
|
if rr.Code != 200 {
|
|
t.Fatal(rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBootstrapDescribesBatRelationship(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
idx, err := LoadIndexFromResourceRoot(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
cfg.PublicBaseURL = "http://127.0.0.1:18080"
|
|
cfg.SocketPath = "/tmp/bat-pid/bat.sock"
|
|
_ = cfg.Normalize()
|
|
healthy := true
|
|
s := NewServer(cfg, nil, nil)
|
|
s.mu.Lock()
|
|
s.idx = idx
|
|
s.meta = DiscoverResult{
|
|
Index: idx,
|
|
ResourceRoot: root,
|
|
RPCAvailable: true,
|
|
DoctorHealthy: &healthy,
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["ready"] != true {
|
|
t.Fatalf("ready=%v", body["ready"])
|
|
}
|
|
bat := body["bat"].(map[string]any)
|
|
if bat["role"] != "sync_daemon_and_release_producer" {
|
|
t.Fatalf("bat role=%v", bat["role"])
|
|
}
|
|
resource := body["resource"].(map[string]any)
|
|
wantRoot := "http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/r93_fixture"
|
|
if resource["addressables_catalog_url_root"] != wantRoot {
|
|
t.Fatalf("addressables root=%v", resource["addressables_catalog_url_root"])
|
|
}
|
|
if resource["server_info_url"] != "http://127.0.0.1:18080/yostar-serverinfo.bluearchiveyostar.com/server-info.json" {
|
|
t.Fatalf("server info url=%v", resource["server_info_url"])
|
|
}
|
|
policy := body["policy"].(map[string]any)
|
|
if policy["pull_owner"] != "rust_bat" || policy["writes_release_state"] != false {
|
|
t.Fatalf("policy=%v", policy)
|
|
}
|
|
}
|
|
|
|
func TestLauncherBootstrapDescribesResourceOnlyScope(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
idx, err := LoadIndexFromResourceRoot(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
cfg.PublicBaseURL = "http://127.0.0.1:18080"
|
|
_ = cfg.Normalize()
|
|
s := NewServer(cfg, nil, nil)
|
|
s.mu.Lock()
|
|
s.idx = idx
|
|
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
|
|
s.mu.Unlock()
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/launcher/bootstrap", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["ready"] != true {
|
|
t.Fatalf("ready=%v", body["ready"])
|
|
}
|
|
policy := body["policy"].(map[string]any)
|
|
if policy["source"] != "rust_bat_snapshot" || policy["emulates_login_or_gateway"] != false {
|
|
t.Fatalf("policy=%v", policy)
|
|
}
|
|
if policy["downloads_launcher_package"] != false || policy["package_update_manifest"] != false {
|
|
t.Fatalf("policy=%v", policy)
|
|
}
|
|
metadata := body["launcher_metadata"].(map[string]any)
|
|
if metadata["game_latest_file_path"] != "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip" {
|
|
t.Fatalf("launcher metadata=%v", metadata)
|
|
}
|
|
gameMainConfig := body["game_main_config"].(map[string]any)
|
|
if gameMainConfig["server_info_data_url"] != "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" {
|
|
t.Fatalf("game main config=%v", gameMainConfig)
|
|
}
|
|
if body["addressables_catalog_url_root"] != "http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/r93_fixture" {
|
|
t.Fatalf("addressables root=%v", body["addressables_catalog_url_root"])
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, launcherHostPath("/api/launcher/resource/bootstrap.json"), nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("host-shaped bootstrap status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLauncherCompatibilityEndpoints(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
idx, err := LoadIndexFromResourceRoot(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
cfg.PublicBaseURL = "http://127.0.0.1:18080"
|
|
_ = cfg.Normalize()
|
|
s := NewServer(cfg, nil, nil)
|
|
s.mu.Lock()
|
|
s.idx = idx
|
|
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
|
|
s.mu.Unlock()
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, launcherHostPath("/api/launcher/game/config"), nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("game config status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var envelope map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if envelope["code"] != float64(200) {
|
|
t.Fatalf("envelope=%v", envelope)
|
|
}
|
|
data := envelope["data"].(map[string]any)
|
|
if data["game_latest_version"] != "1.70.0" {
|
|
t.Fatalf("game config=%v", data)
|
|
}
|
|
if data["game_latest_file_path"] != "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip" {
|
|
t.Fatalf("game config=%v", data)
|
|
}
|
|
if data["resource_bootstrap_url"] != "http://127.0.0.1:18080/v1/launcher/bootstrap" {
|
|
t.Fatalf("game config=%v", data)
|
|
}
|
|
if data["server_info_url"] != "http://127.0.0.1:18080/yostar-serverinfo.bluearchiveyostar.com/server-info.json" {
|
|
t.Fatalf("game config=%v", data)
|
|
}
|
|
if params := data["game_start_params"].([]any); len(params) != 1 || params[0] != "BlueArchive.exe" {
|
|
t.Fatalf("params=%v", data["game_start_params"])
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/launcher/game/config", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bare game config status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
req := httptest.NewRequest(
|
|
http.MethodGet,
|
|
launcherHostPath("/api/launcher/game/config/json")+"?version=1.70.0&file_path=prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2FBlueArchive_JP-1.70.436321-game.zip",
|
|
nil,
|
|
)
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("game config json status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
envelope = map[string]any{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data = envelope["data"].(map[string]any)
|
|
if data["package_update_manifest"] != false || data["scope"] != "resource_bootstrap_only" {
|
|
t.Fatalf("game config json=%v", data)
|
|
}
|
|
url, _ := data["url"].(string)
|
|
if !strings.HasPrefix(url, "http://127.0.0.1:18080/api-launcher-jp.yo-star.com/api/launcher/resource/bootstrap.json?") {
|
|
t.Fatalf("bootstrap url=%q", url)
|
|
}
|
|
if !strings.Contains(url, "version=1.70.0") || !strings.Contains(url, "file_path=prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2FBlueArchive_JP-1.70.436321-game.zip") {
|
|
t.Fatalf("bootstrap url=%q", url)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, launcherHostPath("/api/launcher/advanced/game/download/cdn"), nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("cdn config status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
envelope = map[string]any{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data = envelope["data"].(map[string]any)
|
|
if data["primary_cdn"] != "http://127.0.0.1:18080" || data["back_up_cdn"] != "http://127.0.0.1:18080" {
|
|
t.Fatalf("cdn config=%v", data)
|
|
}
|
|
if data["package_update_manifest"] != false {
|
|
t.Fatalf("cdn config=%v", data)
|
|
}
|
|
}
|
|
|
|
func TestBootstrapNotReadyDoesNotClaimReleaseOwnership(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.PublicBaseURL = "http://127.0.0.1:18080"
|
|
cfg.SocketPath = "/tmp/bat-pid/bat.sock"
|
|
_ = cfg.Normalize()
|
|
s := NewServer(cfg, nil, nil)
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil))
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["ready"] != false {
|
|
t.Fatalf("ready=%v", body["ready"])
|
|
}
|
|
policy := body["policy"].(map[string]any)
|
|
if policy["pull_owner"] != "rust_bat" {
|
|
t.Fatalf("pull owner=%v", policy["pull_owner"])
|
|
}
|
|
if policy["serves_only_published_release"] != true || policy["writes_release_state"] != false {
|
|
t.Fatalf("policy=%v", policy)
|
|
}
|
|
resource := body["resource"].(map[string]any)
|
|
if resource["entry_count"] != float64(0) || resource["present_count"] != float64(0) {
|
|
t.Fatalf("resource=%v", resource)
|
|
}
|
|
}
|
|
|
|
func TestServerInfoRewritesAddressablesOnly(t *testing.T) {
|
|
raw := []byte(`{
|
|
"ConnectionGroups":[{
|
|
"Name":"Prod",
|
|
"AddressablesCatalogUrlRoot":"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture",
|
|
"ApiUrl":"https://prod-game.example.invalid/"
|
|
}]
|
|
}`)
|
|
out, err := RewriteServerInfoAddressables(raw, "http://127.0.0.1:18080")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(out, &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
group := doc["ConnectionGroups"].([]any)[0].(map[string]any)
|
|
got := group["AddressablesCatalogUrlRoot"].(string)
|
|
want := "http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/r93_fixture"
|
|
if got != want {
|
|
t.Fatalf("root=%s want=%s", got, want)
|
|
}
|
|
if group["ApiUrl"] != "https://prod-game.example.invalid/" {
|
|
t.Fatalf("ApiUrl should be unchanged: %v", group["ApiUrl"])
|
|
}
|
|
}
|
|
|
|
type fakeBackend struct {
|
|
statusCalls int
|
|
doctorCalls int
|
|
status *backendrpc.DaemonStatusReport
|
|
doctor *backendrpc.DoctorReport
|
|
catalog json.RawMessage
|
|
manifest *backendrpc.ResourceManifestPage
|
|
}
|
|
|
|
func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
|
|
f.statusCalls++
|
|
return f.status, nil
|
|
}
|
|
func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
|
|
f.doctorCalls++
|
|
if f.statusCalls == 0 {
|
|
// status must be called first in real DiscoverAndIndex; this is asserted by call order.
|
|
}
|
|
return f.doctor, nil
|
|
}
|
|
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
|
|
return &backendrpc.ResourceState{}, nil
|
|
}
|
|
func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
|
return f.catalog, nil
|
|
}
|
|
func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
|
return f.manifest, nil
|
|
}
|
|
|
|
func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
bytes := uint64(20)
|
|
info, err := os.Stat(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := uint64(info.Size())
|
|
_ = bytes
|
|
catalogObj := map[string]any{
|
|
"available": true,
|
|
"status": "published",
|
|
"status_code": "official.published",
|
|
"distribution_status": "ready",
|
|
"distribution_status_code": "distribution.ready",
|
|
"app_version": "1.70.0",
|
|
"bundle_version": "s8tloc7lo3",
|
|
"connection_group_name": "Prod",
|
|
"addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture",
|
|
"game_main_config_bootstrap": map[string]any{
|
|
"server_info_data_url": "https://prod-serverinfo.bluearchiveyostar.com/server-info.json",
|
|
"default_connection_group": "Prod",
|
|
},
|
|
"version": map[string]any{
|
|
"id": "v1",
|
|
"resource_root": root,
|
|
},
|
|
}
|
|
catalogRaw, err := json.Marshal(catalogObj)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fb := &fakeBackend{
|
|
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
|
|
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
|
|
catalog: catalogRaw,
|
|
manifest: &backendrpc.ResourceManifestPage{
|
|
Available: true,
|
|
ResourceRoot: root,
|
|
ManifestVersion: 1,
|
|
TotalEntries: 1,
|
|
Entries: []backendrpc.ResourceManifestEntry{{
|
|
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
|
|
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
|
|
Bytes: &b,
|
|
BLAKE3: strings.Repeat("0", 64),
|
|
}},
|
|
},
|
|
}
|
|
|
|
result, err := DiscoverAndIndex(context.Background(), fb, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fb.statusCalls != 1 || fb.doctorCalls != 1 {
|
|
t.Fatalf("status=%d doctor=%d", fb.statusCalls, fb.doctorCalls)
|
|
}
|
|
if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready {
|
|
t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary())
|
|
}
|
|
if result.DoctorHealthy == nil || !*result.DoctorHealthy {
|
|
t.Fatal("doctor healthy expected")
|
|
}
|
|
summary := result.Index.Summary()
|
|
if summary.Snapshot.Status != "published" || summary.Snapshot.StatusCode != "official.published" {
|
|
t.Fatalf("snapshot status=%q code=%q", summary.Snapshot.Status, summary.Snapshot.StatusCode)
|
|
}
|
|
if summary.Snapshot.DistributionStatusCode != "distribution.ready" {
|
|
t.Fatalf("distribution status code=%q", summary.Snapshot.DistributionStatusCode)
|
|
}
|
|
if summary.Snapshot.GameMainConfig == nil || summary.Snapshot.GameMainConfig.DefaultConnectionGroup != "Prod" {
|
|
t.Fatalf("game main config=%+v", summary.Snapshot.GameMainConfig)
|
|
}
|
|
}
|
|
|
|
func TestLoadEnvFileDoesNotOverride(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, ".env")
|
|
if err := os.WriteFile(path, []byte("BAT_API_LISTEN=:9999\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("BAT_API_LISTEN", ":1111")
|
|
if err := LoadEnvFile(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if os.Getenv("BAT_API_LISTEN") != ":1111" {
|
|
t.Fatal(os.Getenv("BAT_API_LISTEN"))
|
|
}
|
|
}
|
|
|
|
func TestRefreshDiagnosticsAndReadyz(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
cfg.RefreshInterval = 0
|
|
if err := cfg.Normalize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewServer(cfg, nil, log.New(io.Discard, "", 0))
|
|
if err := s.Refresh(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("healthz status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var health map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
refresh := health["refresh"].(map[string]any)
|
|
if refresh["last_success_unix_seconds"] == nil || refresh["last_error"] != "" {
|
|
t.Fatalf("refresh=%v", refresh)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRefreshFailureDiagnosticsAndReadyz(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = filepath.Join(t.TempDir(), "missing-release")
|
|
cfg.RefreshInterval = 0
|
|
if err := cfg.Normalize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewServer(cfg, nil, log.New(io.Discard, "", 0))
|
|
if err := s.Refresh(context.Background()); err == nil {
|
|
t.Fatal("expected refresh failure")
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("healthz status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var health map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
refresh := health["refresh"].(map[string]any)
|
|
if refresh["last_error"] == "" || refresh["last_finished_unix_seconds"] == nil {
|
|
t.Fatalf("refresh=%v", refresh)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestApplyEnvParsesRefreshInterval(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
t.Setenv("BAT_API_REFRESH_INTERVAL", "2m")
|
|
ApplyEnv(&cfg)
|
|
if cfg.RefreshInterval != 2*time.Minute {
|
|
t.Fatalf("refresh interval=%s", cfg.RefreshInterval)
|
|
}
|
|
cfg.RefreshInterval = -time.Second
|
|
if err := cfg.Normalize(); err == nil {
|
|
t.Fatal("expected negative refresh interval error")
|
|
}
|
|
}
|
|
|
|
type pollingBackend struct {
|
|
statusCalls atomic.Int64
|
|
}
|
|
|
|
func (p *pollingBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
|
|
p.statusCalls.Add(1)
|
|
return nil, errors.New("offline")
|
|
}
|
|
func (p *pollingBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
|
|
return nil, errors.New("unexpected doctor call")
|
|
}
|
|
func (p *pollingBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
|
|
return nil, errors.New("unexpected resource state call")
|
|
}
|
|
func (p *pollingBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
|
return nil, errors.New("unexpected catalog call")
|
|
}
|
|
func (p *pollingBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
|
return nil, errors.New("unexpected manifest call")
|
|
}
|
|
|
|
func TestStartRefreshLoopPollsBackend(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.RefreshInterval = 10 * time.Millisecond
|
|
cfg.RPCTimeout = 20 * time.Millisecond
|
|
backend := &pollingBackend{}
|
|
s := NewServer(cfg, backend, log.New(io.Discard, "", 0))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
s.StartRefreshLoop(ctx)
|
|
deadline := time.Now().Add(200 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if backend.statusCalls.Load() > 0 {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatal("refresh loop did not poll backend")
|
|
}
|
|
|
|
func TestRefreshWarningsRecordedWhenRPCUnavailable(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.RefreshInterval = 0
|
|
backend := &pollingBackend{}
|
|
s := NewServer(cfg, backend, log.New(io.Discard, "", 0))
|
|
if err := s.Refresh(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var health map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
refresh := health["refresh"].(map[string]any)
|
|
if refresh["last_error"] != "" || refresh["last_warning_count"] == float64(0) {
|
|
t.Fatalf("refresh=%v", refresh)
|
|
}
|
|
}
|
|
|
|
func TestHTTPAuthRequiresTokenAndAllowsExemptPaths(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
idx, err := LoadIndexFromResourceRoot(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
cfg.AuthToken = "secret-token"
|
|
cfg.AuthExemptPaths = []string{"/healthz"}
|
|
if err := cfg.Normalize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewServer(cfg, nil, nil)
|
|
s.mu.Lock()
|
|
s.idx = idx
|
|
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
|
|
s.mu.Unlock()
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("missing token status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("unauthorized Cache-Control=%q", rr.Header().Get("Cache-Control"))
|
|
}
|
|
var errBody ErrorResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if errBody.Error.Code != "unauthorized" {
|
|
t.Fatalf("error=%+v", errBody)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil)
|
|
req.Header.Set("Authorization", "Bearer secret-token")
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("authorized status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("exempt healthz status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRateLimitUsesForwardedClientWhenTrusted(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.RateLimitRPS = 1
|
|
cfg.RateLimitBurst = 1
|
|
cfg.TrustProxyHeaders = true
|
|
if err := cfg.Normalize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewServer(cfg, nil, log.New(io.Discard, "", 0))
|
|
handler := s.Handler()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
req.RemoteAddr = "10.0.0.10:1234"
|
|
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.10")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("first status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
req.RemoteAddr = "10.0.0.10:1234"
|
|
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.10")
|
|
rr = httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("second status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var errBody ErrorResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if errBody.Error.Code != "rate_limited" {
|
|
t.Fatalf("error=%+v", errBody)
|
|
}
|
|
}
|
|
|
|
func TestResourcesLimitIsCappedAndDynamicResponsesNoStore(t *testing.T) {
|
|
root := fixtureRoot(t)
|
|
idx, err := LoadIndexFromResourceRoot(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
cfg.ResourceRoot = root
|
|
cfg.MaxResourcePageLimit = 1
|
|
cfg.PublicBaseURL = "http://127.0.0.1:18080"
|
|
if err := cfg.Normalize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewServer(cfg, nil, nil)
|
|
s.mu.Lock()
|
|
s.idx = idx
|
|
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
|
|
s.mu.Unlock()
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/resources?limit=999", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("resources status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("resources Cache-Control=%q", rr.Header().Get("Cache-Control"))
|
|
}
|
|
var page ResourceListResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if page.Limit != 1 || len(page.Items) != 1 {
|
|
t.Fatalf("page=%+v", page)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil))
|
|
if rr.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("bootstrap Cache-Control=%q", rr.Header().Get("Cache-Control"))
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/launcher/bootstrap", nil))
|
|
if rr.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("launcher bootstrap Cache-Control=%q", rr.Header().Get("Cache-Control"))
|
|
}
|
|
}
|
|
|
|
func TestAccessLogOmitsQueryString(t *testing.T) {
|
|
var logs bytes.Buffer
|
|
cfg := DefaultConfig()
|
|
cfg.AccessLog = true
|
|
if err := cfg.Normalize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewServer(cfg, nil, log.New(&logs, "", 0))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz?bat_token=secret", nil)
|
|
req.Header.Set("User-Agent", "bat-test")
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
got := logs.String()
|
|
if !strings.Contains(got, "path=/healthz") {
|
|
t.Fatalf("log=%q", got)
|
|
}
|
|
if strings.Contains(got, "secret") || strings.Contains(got, "bat_token") {
|
|
t.Fatalf("log leaked query token: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
if err := cfg.Normalize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewServer(cfg, nil, nil)
|
|
|
|
rr := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("openapi status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("openapi Cache-Control=%q", rr.Header().Get("Cache-Control"))
|
|
}
|
|
if rr.Header().Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Fatalf("X-Content-Type-Options=%q", rr.Header().Get("X-Content-Type-Options"))
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "/v1/launcher/bootstrap") {
|
|
t.Fatalf("openapi missing launcher bootstrap")
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("admin status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var admin AdminIndexResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &admin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if admin.Status != "reserved" {
|
|
t.Fatalf("admin=%+v", admin)
|
|
}
|
|
}
|