refactor(all): reorganize packages, establish player ownership and isolate accounts
This commit is contained in:
@@ -1,208 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
clientconfig "bd2server/internal/client/config"
|
||||
)
|
||||
|
||||
func TestEmbeddedStudioUsesNativeBindings(t *testing.T) {
|
||||
page, err := fs.ReadFile(webFS, "web/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(page)
|
||||
for _, marker := range []string{
|
||||
"BD2 Client Studio", "window.go.app.Studio", "bridge().Initialize()",
|
||||
"(()=>{", "async function navigateScene(next)",
|
||||
`id="directoryScene"`, `id="serverScene"`, `id="deskScene"`,
|
||||
`id="patch"`, `id="install"`, `id="launch"`,
|
||||
`value="official"`, `value="local"`, `value="server"`,
|
||||
"prefers-reduced-motion",
|
||||
"opening-curtain", "is-entering", "@keyframes reveal", "bridge().Quit()",
|
||||
"Asia/Shanghai", "Asia/Hong_Kong", "Asia/Macau", "Asia/Taipei",
|
||||
} {
|
||||
if !strings.Contains(text, marker) {
|
||||
t.Errorf("embedded desktop interface lacks %q", marker)
|
||||
}
|
||||
}
|
||||
for _, obsolete := range []string{"fetch(", "/api/", "bd2-session", "X-BD2-Session", "{{.", "window_darwin.js", "function go("} {
|
||||
if strings.Contains(text, obsolete) {
|
||||
t.Errorf("embedded desktop interface retains obsolete browser bridge %q", obsolete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioInitializeLoadsSavedSettings(t *testing.T) {
|
||||
dir := makeTestClient(t)
|
||||
studio := NewStudio(Options{InitialGameDir: dir, Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10", ClientVersion: "2.35.10+client.0.1.0"}}, NativeHost{})
|
||||
if state := studio.Initialize(); state.AutoOpen {
|
||||
t.Fatal("missing settings enabled automatic workspace")
|
||||
}
|
||||
if _, err := clientconfig.Save(dir, clientconfig.Settings{ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := studio.Initialize()
|
||||
if !state.AutoOpen || state.GameDirectory != dir || state.ServerOrigin != "https://play.example.com" || state.ClientVersion != "2.35.10+client.0.1.0" || state.GameVersion != "2.35.10" {
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioSaveRemembersOnlyValidatedSettings(t *testing.T) {
|
||||
dir := makeTestClient(t)
|
||||
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10"}}, NativeHost{})
|
||||
var remembered string
|
||||
studio.savePreferences = func(value string) error { remembered = value; return nil }
|
||||
input := Request{GameDirectory: dir, ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}
|
||||
result, err := studio.Save(input)
|
||||
if err != nil || !result.OK || remembered != dir {
|
||||
t.Fatalf("result=%+v remembered=%q err=%v", result, remembered, err)
|
||||
}
|
||||
remembered = ""
|
||||
input.ServerOrigin = "http://untrusted.example.com"
|
||||
if _, err := studio.Save(input); err == nil || remembered != "" {
|
||||
t.Fatalf("invalid settings remembered=%q err=%v", remembered, err)
|
||||
}
|
||||
if _, err := studio.Launch(Request{GameDirectory: dir}); err == nil {
|
||||
t.Fatal("unpatched game launch accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioContextCancellationAndPickerCancellation(t *testing.T) {
|
||||
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) { return "", nil }})
|
||||
for _, browse := range []func(Request) (Response, error){studio.Browse, studio.BrowseResources} {
|
||||
result, err := browse(Request{})
|
||||
if err != nil || !result.OK || result.Message != "Selection cancelled" || result.Data != nil {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
studio.Startup(ctx)
|
||||
cancel()
|
||||
if _, err := studio.Resources(Request{ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioShutdownCancelsOwnedContext(t *testing.T) {
|
||||
studio := NewStudio(Options{}, NativeHost{})
|
||||
studio.Startup(context.Background())
|
||||
owned := studio.context()
|
||||
studio.Shutdown()
|
||||
if !errors.Is(owned.Err(), context.Canceled) {
|
||||
t.Fatalf("owned context err=%v", owned.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioInitializeDefaultsWithoutGameDirectory(t *testing.T) {
|
||||
studio := NewStudio(Options{LogPath: "test.log"}, NativeHost{})
|
||||
studio.Startup(context.Background())
|
||||
state := studio.Initialize()
|
||||
if state.Platform == "" || state.LogPath != "test.log" || state.GameDirectory != "" {
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
if state.ServerOrigin != "http://127.0.0.1:8080" || state.CDNMode != clientconfig.CDNOfficial || state.AutoOpen {
|
||||
t.Fatalf("defaults=%+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioBrowseUsesNativeDirectoryPicker(t *testing.T) {
|
||||
dir := makeTestClient(t)
|
||||
var title string
|
||||
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10"}}, NativeHost{
|
||||
BrowseDirectory: func(_ context.Context, requested string) (string, error) {
|
||||
title = requested
|
||||
return dir, nil
|
||||
},
|
||||
})
|
||||
studio.Startup(context.Background())
|
||||
result, err := studio.Browse(Request{UILanguage: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.OK || result.Data == nil || title != "选择 Brown Dust II 安装目录" {
|
||||
t.Fatalf("result=%+v title=%q", result, title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioQuitUsesNativeLifecycleOnce(t *testing.T) {
|
||||
quits := 0
|
||||
studio := NewStudio(Options{}, NativeHost{Quit: func(context.Context) { quits++ }})
|
||||
studio.Startup(context.Background())
|
||||
studio.Quit()
|
||||
studio.Quit()
|
||||
if quits != 1 {
|
||||
t.Fatalf("quits=%d", quits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioPropagatesOperationErrors(t *testing.T) {
|
||||
want := errors.New("picker failed")
|
||||
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) {
|
||||
return "", want
|
||||
}})
|
||||
studio.Startup(context.Background())
|
||||
if _, err := studio.Browse(Request{}); !errors.Is(err, want) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSecurityHeaders(t *testing.T) {
|
||||
called := false
|
||||
handler := desktopSecurityHeaders(httpHandlerFunc(func(header map[string]string) {
|
||||
called = true
|
||||
if header["Cache-Control"] != "no-store" || header["Content-Security-Policy"] == "" {
|
||||
t.Fatalf("headers=%v", header)
|
||||
}
|
||||
}))
|
||||
response := &headerRecorder{header: make(map[string][]string)}
|
||||
handler.ServeHTTP(response, nil)
|
||||
if !called {
|
||||
t.Fatal("asset middleware did not call the next handler")
|
||||
}
|
||||
}
|
||||
|
||||
// Small local adapters keep this middleware test independent of httptest's
|
||||
// network-shaped helpers.
|
||||
type httpHandlerFunc func(map[string]string)
|
||||
|
||||
func (f httpHandlerFunc) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
headers := make(map[string]string)
|
||||
for name, values := range w.Header() {
|
||||
if len(values) != 0 {
|
||||
headers[name] = values[0]
|
||||
}
|
||||
}
|
||||
f(headers)
|
||||
}
|
||||
|
||||
type headerRecorder struct{ header http.Header }
|
||||
|
||||
func (r *headerRecorder) Header() http.Header { return r.header }
|
||||
func (*headerRecorder) Write([]byte) (int, error) { return 0, nil }
|
||||
func (*headerRecorder) WriteHeader(int) {}
|
||||
|
||||
func makeTestClient(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("not a real Unity file"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
@@ -31,6 +31,7 @@ func Run(runOptions Options) error {
|
||||
studio.log().Info("starting native client window", "windows_engine", "WebView2", "macos_engine", "WKWebView")
|
||||
err = wails.Run(&options.App{
|
||||
Title: "BD2 Client Studio",
|
||||
Frameless: true,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
MinWidth: 860,
|
||||
@@ -52,13 +53,12 @@ func Run(runOptions Options) error {
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
Theme: windows.Light,
|
||||
BackdropType: windows.Mica,
|
||||
DisablePinchZoom: true,
|
||||
IsZoomControlEnabled: false,
|
||||
EnableSwipeGestures: false,
|
||||
},
|
||||
Mac: &mac.Options{
|
||||
TitleBar: mac.TitleBarDefault(),
|
||||
TitleBar: &mac.TitleBar{HideTitle: true, HideTitleBar: true, FullSizeContent: true, TitlebarAppearsTransparent: true},
|
||||
Appearance: mac.NSAppearanceNameAqua,
|
||||
DisableZoom: true,
|
||||
},
|
||||
|
||||
@@ -28,16 +28,3 @@ func gameProxyEnvironment(environment []string, proxyURL string) []string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LaunchServices needs explicit empty values when the player selects direct.
|
||||
func gameOpenArguments(target string, environment []string, proxyURL string) []string {
|
||||
args := []string{target}
|
||||
for _, entry := range gameProxyEnvironment(environment, proxyURL) {
|
||||
key, _, found := strings.Cut(entry, "=")
|
||||
if found && isGameProxyEnvironmentKey(key) {
|
||||
args = append(args, "--env", entry)
|
||||
}
|
||||
}
|
||||
args = append(args, "--args")
|
||||
return append(args, gameLaunchArguments()...)
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGameProxyEnvironmentAuthoritative(t *testing.T) {
|
||||
input := []string{"PATH=kept", "UNITY_PROXYSERVER=http://stale:8080", "uNiTy_NoPrOxY=*", "http_proxy=http://stale:8080", "HTTPS_PROXY=http://stale:8080", "All_Proxy=http://stale:8080", "NO_PROXY=*", "bd2_client_proxy_url=http://stale:8080"}
|
||||
original := append([]string(nil), input...)
|
||||
for _, proxy := range []string{"", "http://127.0.0.1:12451"} {
|
||||
got := gameProxyEnvironment(input, proxy)
|
||||
if !reflect.DeepEqual(got, gameProxyEnvironment(got, proxy)) {
|
||||
t.Fatal("environment is not idempotent")
|
||||
}
|
||||
if got[0] != "PATH=kept" || !reflect.DeepEqual(input, original) {
|
||||
t.Fatal("unrelated environment or input changed")
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, entry := range got[1:] {
|
||||
key, value, _ := strings.Cut(entry, "=")
|
||||
if !isGameProxyEnvironmentKey(key) || strings.Contains(value, "stale") {
|
||||
t.Fatal("stale proxy survived")
|
||||
}
|
||||
seen[key] = value
|
||||
}
|
||||
for _, key := range []string{"UNITY_PROXYSERVER", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "BD2_CLIENT_PROXY_URL"} {
|
||||
if value, ok := seen[key]; !ok || value != proxy {
|
||||
t.Errorf("missing authoritative %s override", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"UNITY_NOPROXY", "NO_PROXY", "no_proxy"} {
|
||||
if seen[key] != "localhost,127.0.0.1,::1" {
|
||||
t.Errorf("loopback bypass missing for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameProxyEnvironmentKeys(t *testing.T) {
|
||||
for _, key := range []string{"UNITY_PROXYSERVER", "unity_noproxy", "http_proxy", "HTTPS_PROXY", "All_Proxy", "NO_PROXY", "bd2_client_proxy_url"} {
|
||||
if !isGameProxyEnvironmentKey(key) {
|
||||
t.Errorf("proxy key %q not recognized", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"PATH", "SECRET", "HTTP_PROXY_PASSWORD", "NO_PROXY_EXTRA"} {
|
||||
if isGameProxyEnvironmentKey(key) {
|
||||
t.Errorf("unrelated key %q recognized", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameOpenArgumentsOverridesLaunchServicesProxy(t *testing.T) {
|
||||
args := gameOpenArguments("/Applications/BrownDust II.app", []string{"HTTP_PROXY=http://stale:80", "PATH=private"}, "")
|
||||
overrides := map[string]bool{}
|
||||
for index := 1; index < len(args) && args[index] != "--args"; index += 2 {
|
||||
if args[index] != "--env" {
|
||||
t.Fatal("missing explicit LaunchServices environment flag")
|
||||
}
|
||||
overrides[args[index+1]] = true
|
||||
}
|
||||
for _, entry := range []string{"HTTP_PROXY=", "http_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "UNITY_PROXYSERVER=", "BD2_CLIENT_PROXY_URL=", "UNITY_NOPROXY=localhost,127.0.0.1,::1"} {
|
||||
if !overrides[entry] {
|
||||
t.Errorf("LaunchServices override missing: %s", entry)
|
||||
}
|
||||
}
|
||||
if overrides["PATH=private"] || overrides["HTTP_PROXY=http://stale:80"] {
|
||||
t.Fatal("unrelated or stale environment forwarded")
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestOpenPersistentLoggerUsesExecutableLogDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
executable := filepath.Join(root, "bd2client.exe")
|
||||
logger, closer, logPath, err := OpenPersistentLogger(executable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger.Info("test entry")
|
||||
if err := closer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPath := filepath.Join(root, "logs", clientLogName)
|
||||
if logPath != wantPath {
|
||||
t.Fatalf("log path=%q want=%q", logPath, wantPath)
|
||||
}
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte("test entry")) {
|
||||
t.Fatalf("log does not contain test entry: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentOperationalLogsUseEnglish(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&output, nil))
|
||||
studio := NewStudio(Options{Logger: logger}, NativeHost{})
|
||||
studio.Startup(context.Background())
|
||||
_, _ = studio.Inspect(Request{GameDirectory: `Z:\missing`, UILanguage: "zh-CN"})
|
||||
for len(output.Bytes()) > 0 {
|
||||
r, size := utf8.DecodeRune(output.Bytes())
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
t.Fatalf("operational log contains Han character %q: %s", r, output.String())
|
||||
}
|
||||
output.Next(size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollingLogRetainsOneBackup(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, clientLogName)
|
||||
backup := filepath.Join(root, clientLogBackupName)
|
||||
writer, err := openRollingLog(path, backup, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("first")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("second")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := os.ReadFile(backup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(active) != "second" || string(previous) != "first" {
|
||||
t.Fatalf("active=%q backup=%q", active, previous)
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,16 @@ func launchGame(target, proxyURL string) error {
|
||||
args := gameOpenArguments(target, os.Environ(), proxyURL)
|
||||
return exec.Command("open", args...).Start()
|
||||
}
|
||||
|
||||
// LaunchServices needs explicit empty values when the player selects direct.
|
||||
func gameOpenArguments(target string, environment []string, proxyURL string) []string {
|
||||
args := []string{target}
|
||||
for _, entry := range gameProxyEnvironment(environment, proxyURL) {
|
||||
key, _, found := strings.Cut(entry, "=")
|
||||
if found && isGameProxyEnvironmentKey(key) {
|
||||
args = append(args, "--env", entry)
|
||||
}
|
||||
}
|
||||
args = append(args, "--args")
|
||||
return append(args, gameLaunchArguments()...)
|
||||
}
|
||||
|
||||
@@ -54,18 +54,8 @@ func ShowFatalError(err error) {
|
||||
// despite inheriting no console from the parent process.
|
||||
const createNoWindow = 0x08000000
|
||||
|
||||
func hiddenCommand(name string, args ...string) *exec.Cmd {
|
||||
command := exec.Command(name, args...)
|
||||
command.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: createNoWindow,
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
// visibleCommand suppresses a console allocation without hiding the GUI
|
||||
// window created by the child process. It must be used for the game itself;
|
||||
// hiddenCommand is reserved for background helper processes.
|
||||
// window created by the child process.
|
||||
func visibleCommand(name string, args ...string) *exec.Cmd {
|
||||
command := exec.Command(name, args...)
|
||||
command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHiddenCommandNeverAllocatesVisibleConsole(t *testing.T) {
|
||||
command := hiddenCommand("powershell.exe", "-NoProfile")
|
||||
if command.SysProcAttr == nil {
|
||||
t.Fatal("hidden command has no Windows process attributes")
|
||||
}
|
||||
if !command.SysProcAttr.HideWindow {
|
||||
t.Fatal("hidden command does not request a hidden window")
|
||||
}
|
||||
if command.SysProcAttr.CreationFlags&createNoWindow == 0 {
|
||||
t.Fatalf("hidden command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleCommandDoesNotHideGUIWindow(t *testing.T) {
|
||||
command := visibleCommand("Brown Dust II.exe", gameLaunchArguments()...)
|
||||
if command.SysProcAttr == nil {
|
||||
t.Fatal("visible command has no Windows process attributes")
|
||||
}
|
||||
if command.SysProcAttr.HideWindow {
|
||||
t.Fatal("visible game command requests a hidden window")
|
||||
}
|
||||
if command.SysProcAttr.CreationFlags&createNoWindow == 0 {
|
||||
t.Fatalf("visible command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags)
|
||||
}
|
||||
want := []string{
|
||||
"Brown Dust II.exe",
|
||||
"-screen-fullscreen", "1",
|
||||
"-window-mode", "borderless",
|
||||
}
|
||||
if !reflect.DeepEqual(command.Args, want) {
|
||||
t.Fatalf("visible game command args = %#v, want %#v", command.Args, want)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,133 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
got, err := Normalize(Settings{ServerOrigin: " https://example.com:8443/ ", CDNMode: CDNServer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.SchemaVersion != SchemaVersion || got.ServerOrigin != "https://example.com:8443" || got.CDNMode != CDNServer {
|
||||
t.Fatalf("normalized=%+v", got)
|
||||
}
|
||||
for _, bad := range []string{"example.com", "ftp://example.com", "http://192.168.1.8:8080", "https://u:p@example.com", "https://example.com/game/", "https://example.com?q=1"} {
|
||||
if _, err := Normalize(Settings{ServerOrigin: bad, CDNMode: CDNOfficial}); err == nil {
|
||||
t.Errorf("accepted origin %q", bad)
|
||||
}
|
||||
}
|
||||
localRoot := t.TempDir()
|
||||
local, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: localRoot})
|
||||
if err != nil || local.LocalResourceDirectory != localRoot {
|
||||
t.Fatalf("local=%+v err=%v", local, err)
|
||||
}
|
||||
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal}); err == nil {
|
||||
t.Fatal("accepted local mode without a resource directory")
|
||||
}
|
||||
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, LocalResourceDirectory: localRoot}); err == nil {
|
||||
t.Fatal("accepted a local resource directory in official mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: t.TempDir()}
|
||||
if _, err := Save(dir, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ServerOrigin != want.ServerOrigin || got.CDNMode != want.CDNMode || got.SchemaVersion != SchemaVersion || got.LocalResourceDirectory != want.LocalResourceDirectory {
|
||||
t.Fatalf("loaded=%+v", got)
|
||||
}
|
||||
data, err := os.ReadFile(Path(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "secret") || strings.Contains(string(data), "token") {
|
||||
t.Fatalf("client config unexpectedly stores a credential: %s", data)
|
||||
}
|
||||
updated := Settings{ServerOrigin: "https://friends.example:8443", CDNMode: CDNServer}
|
||||
if _, err := Save(dir, updated); err != nil {
|
||||
t.Fatalf("replace config: %v", err)
|
||||
}
|
||||
got, err = Load(dir)
|
||||
if err != nil || got.ServerOrigin != updated.ServerOrigin || got.CDNMode != updated.CDNMode {
|
||||
t.Fatalf("replaced=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveOmitsLocalDirectoryOutsideLocalMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Save(dir, Settings{ServerOrigin: "https://example.com", CDNMode: CDNOfficial}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(Path(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "local_resource_directory") {
|
||||
t.Fatalf("official config contains local directory field: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathUsesMacAppSiblingBepInEx(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
app := filepath.Join(parent, "BrownDust II.app")
|
||||
for _, path := range []string{
|
||||
filepath.Join(app, "Contents", "MacOS", "BrownDust II"),
|
||||
filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("test"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := filepath.Join(parent, "BepInEx", "config", FileName)
|
||||
if got := Path(app); got != want {
|
||||
t.Fatalf("Path()=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitProxyNormalization(t *testing.T) {
|
||||
for _, test := range []struct{ raw, want string }{
|
||||
{"", ""}, {" ", ""}, {" http://127.0.0.1:12451/ ", "http://127.0.0.1:12451"}, {"http://[::1]:8080", "http://[::1]:8080"}, {"http://proxy.example:08080", "http://proxy.example:8080"},
|
||||
} {
|
||||
got, err := NormalizeProxyURL(test.raw)
|
||||
if err != nil || got != test.want {
|
||||
t.Errorf("normalize proxy: got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"http://proxy", "https://proxy:443", "socks5://proxy:1080", "http://user:secret@proxy:8080", "http://proxy:0", "http://proxy:65536", "http://proxy:http", "http://proxy:+80", "http://proxy:8080/path", "http://proxy:8080?secret", "http://proxy:8080?", "http://proxy:8080#", "http://proxy:8080#secret", "http://:8080", "http://[::1%25zone]:8080"} {
|
||||
if _, err := NormalizeProxyURL(bad); err == nil {
|
||||
t.Errorf("invalid proxy accepted: %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxySettingsPersistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
saved, err := Save(dir, Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, ProxyURL: "http://localhost:12451/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(dir)
|
||||
if err != nil || loaded.ProxyURL != "http://localhost:12451" || saved != loaded {
|
||||
t.Fatalf("proxy not persisted: %#v, %v", loaded, err)
|
||||
}
|
||||
if _, err := Save(dir, Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err = Load(dir)
|
||||
if err != nil || loaded.ProxyURL != "" {
|
||||
t.Fatal("omitted proxy should use direct connection")
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPreferencesRoundTrip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("APPDATA", root)
|
||||
game := filepath.Join(root, "game")
|
||||
if err := SavePreferences(game); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadPreferences()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, _ := filepath.Abs(game)
|
||||
if got.SchemaVersion != preferencesSchemaVersion || got.GameDirectory != want {
|
||||
t.Fatalf("preferences=%+v", got)
|
||||
}
|
||||
path, err := PreferencesPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadReleaseVersions(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ReleaseFileName)
|
||||
data := `{"game_version":"2.35.10","client_version":"2.35.10+client.0.1.0","server_version":"2.35.10+server.0.1.0","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"go/seed/v2_35_10","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadReleaseVersions(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.GameVersion != "2.35.10" || got.ClientVersion != "2.35.10+client.0.1.0" || got.ServerVersion != "2.35.10+server.0.1.0" || got.BundleVersion != "20260921135230" || got.GameDataVersion != "20260923193640" {
|
||||
t.Fatalf("versions=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReleaseVersionsRejectsUnknownAndTrailingData(t *testing.T) {
|
||||
for name, data := range map[string]string{
|
||||
"unknown": `{"game_version":"2.35.10","unknown":true}`,
|
||||
"wrong-component": `{"game_version":"2.35.10","client_version":"2.35.10+server.0.1.0","server_version":"2.35.10+server.0.1.0"}`,
|
||||
"trailing": `{}` + `{}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ReleaseFileName)
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadReleaseVersions(path); err == nil {
|
||||
t.Fatal("accepted invalid release versions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package introdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func referenceClientDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := os.Getenv("BD2_TEST_CLIENT_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("set BD2_TEST_CLIENT_DIR to enable read-only client integration tests")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestPagesRoundTrip(t *testing.T) {
|
||||
p := make([]byte, PageSize*2)
|
||||
copy(p, salt)
|
||||
for i := 16; i < len(p); i++ {
|
||||
p[i] = byte(i * 31)
|
||||
}
|
||||
c, err := EncryptPages(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecryptPages(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, p) {
|
||||
t.Fatal("page cipher did not round-trip")
|
||||
}
|
||||
}
|
||||
func TestPagesRejectPartialPage(t *testing.T) {
|
||||
if _, err := DecryptPages(make([]byte, PageSize-1)); err == nil {
|
||||
t.Fatal("accepted partial page")
|
||||
}
|
||||
}
|
||||
func TestValidateRequiresUniqueURL(t *testing.T) {
|
||||
p := append([]byte{}, salt...)
|
||||
p = append(p, []byte(" ServerURLTable LIVE_URL "+oldURL)...)
|
||||
if err := validateIntroDB(p, oldURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateIntroDB(append(p, []byte(oldURL)...), oldURL); err == nil {
|
||||
t.Fatal("accepted duplicated URL")
|
||||
}
|
||||
}
|
||||
|
||||
// This is deliberately read-only. When the reference client is present, it
|
||||
// proves the Unity metadata parser and crypto parameters against the real file.
|
||||
func TestKnownClientVerify(t *testing.T) {
|
||||
knownClientDir := referenceClientDir(t)
|
||||
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
|
||||
t.Skip("reference client is not available")
|
||||
}
|
||||
v, err := VerifyClient(knownClientDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.URL != oldURL && v.URL != "http://127.0.0.1:8080/game/" {
|
||||
t.Fatalf("unexpected LIVE_URL=%q", v.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// The transaction test copies the reference asset into a test directory, never
|
||||
// mutating the installed client. It covers Unity object lookup, backup,
|
||||
// encryption, atomic replacement, and the post-patch diagnostic together.
|
||||
func TestPatchClientTransaction(t *testing.T) {
|
||||
knownClientDir := referenceClientDir(t)
|
||||
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
|
||||
t.Skip("reference client is not available")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
dir := filepath.Join(tmp, "BrownDust II_Data")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src, err := ResourcesPath(knownClientDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current, err := VerifyClient(knownClientDir); err == nil && current.URL != oldURL {
|
||||
// The installed research client is normally patched. Exercise the
|
||||
// transaction against its immutable pre-patch backup in that case.
|
||||
if _, err := os.Stat(src + ".bak"); err != nil {
|
||||
t.Skipf("official pre-patch asset is unavailable: %v", err)
|
||||
}
|
||||
src += ".bak"
|
||||
}
|
||||
dst := filepath.Join(dir, "resources.assets")
|
||||
in, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, in, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const local = "http://127.0.0.1:8080/bd2x/"
|
||||
r, err := PatchClient(tmp, local)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.OldURL != oldURL || r.NewURL != local || r.BackupPath != dst+".bak" {
|
||||
t.Fatalf("unexpected patch result: %#v", r)
|
||||
}
|
||||
if !r.Changed {
|
||||
t.Fatal("first patch was not reported as changed")
|
||||
}
|
||||
if _, err := os.Stat(r.BackupPath); err != nil {
|
||||
t.Fatalf("backup missing: %v", err)
|
||||
}
|
||||
v, err := VerifyClient(tmp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.URL != local {
|
||||
t.Fatalf("LIVE_URL=%q, want %q", v.URL, local)
|
||||
}
|
||||
repeated, err := PatchClient(tmp, local)
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent patch: %v", err)
|
||||
}
|
||||
if repeated.Changed {
|
||||
t.Fatal("idempotent patch was reported as changed")
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("test"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWindows(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "BrownDust II.exe"))
|
||||
writeFile(t, filepath.Join(root, "BrownDust II_Data", "resources.assets"))
|
||||
got, err := Resolve(root)
|
||||
if err != nil || got.Kind != Windows || got.Resources != filepath.Join(root, "BrownDust II_Data", "resources.assets") || got.Plugins != filepath.Join(root, "BepInEx", "plugins") {
|
||||
t.Fatalf("layout=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMacAppAndParent(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
app := filepath.Join(parent, "BrownDust II.app")
|
||||
writeFile(t, filepath.Join(app, "Contents", "MacOS", "BrownDust II"))
|
||||
writeFile(t, filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"))
|
||||
for _, selected := range []string{app, parent} {
|
||||
got, err := Resolve(selected)
|
||||
if err != nil || got.Kind != MacOS || got.Root != app || got.BepInEx != filepath.Join(parent, "BepInEx") || got.LaunchTarget() != app {
|
||||
t.Fatalf("selected=%q layout=%+v err=%v", selected, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
|
||||
t.Run(spec.FileName(), func(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), spec.FileName())
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Install(spec, gameDir, source)
|
||||
if err == nil || !strings.Contains(err.Error(), BepInExReleasesURL) {
|
||||
t.Fatalf("missing BepInEx error=%v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("plugin was copied without BepInEx: %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
|
||||
t.Run(spec.FileName(), func(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), spec.FileName())
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := Install(spec, gameDir, source)
|
||||
if err != nil || !first.Changed {
|
||||
t.Fatalf("first install=%+v err=%v", first, err)
|
||||
}
|
||||
second, err := Install(spec, gameDir, source)
|
||||
if err != nil || second.Changed {
|
||||
t.Fatalf("idempotent install=%+v err=%v", second, err)
|
||||
}
|
||||
runtimeSource := filepath.Join(filepath.Dir(source), GameNames.FileName())
|
||||
if err := os.WriteFile(runtimeSource, []byte("names-v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimeUpdate, err := Install(spec, gameDir, source)
|
||||
if err != nil || !runtimeUpdate.Changed {
|
||||
t.Fatalf("runtime-only update=%+v err=%v", runtimeUpdate, err)
|
||||
}
|
||||
installedRuntime, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", GameNames.FileName()))
|
||||
if err != nil || string(installedRuntime) != "names-v2" {
|
||||
t.Fatalf("installed runtime=%q err=%v", installedRuntime, err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third, err := Install(spec, gameDir, source)
|
||||
if err != nil || !third.Changed {
|
||||
t.Fatalf("update=%+v err=%v", third, err)
|
||||
}
|
||||
got, err := os.ReadFile(third.Destination)
|
||||
if err != nil || string(got) != "v2" {
|
||||
t.Fatalf("installed=%q err=%v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallMissingSharedRuntimeDoesNotChangePlugin(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()): []byte("old-plugin"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), LocalIdentity.FileName())
|
||||
if err := os.WriteFile(source, []byte("new-plugin"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Install(LocalIdentity, gameDir, source); err == nil || !strings.Contains(err.Error(), GameNames.FileName()) {
|
||||
t.Fatalf("missing runtime error=%v", err)
|
||||
}
|
||||
installed, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()))
|
||||
if err != nil || string(installed) != "old-plugin" {
|
||||
t.Fatalf("installed=%q err=%v", installed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallKeepsPluginsSeparate(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
|
||||
source := filepath.Join(t.TempDir(), spec.FileName())
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte(spec.FileName()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Install(spec, gameDir, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
|
||||
path := filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != spec.FileName() {
|
||||
t.Fatalf("%s=%q err=%v", spec.FileName(), data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,11 +137,6 @@ func readCatalog(path string) ([]byte, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func validateLocalCatalog(raw []byte, release string) error {
|
||||
_, err := localizeCatalogIDs(raw, release)
|
||||
return err
|
||||
}
|
||||
|
||||
func localizeCatalog(raw []byte, release, aa string) ([]byte, error) {
|
||||
document, ids, err := decodeCatalog(raw)
|
||||
if err != nil {
|
||||
@@ -185,31 +180,6 @@ func localizeCatalog(raw []byte, release, aa string) ([]byte, error) {
|
||||
return localized, nil
|
||||
}
|
||||
|
||||
func localizeCatalogIDs(raw []byte, release string) ([]string, error) {
|
||||
_, ids, err := decodeCatalog(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteCount := 0
|
||||
for _, internalID := range ids {
|
||||
if !strings.HasPrefix(internalID, remoteCatalogPrefix) {
|
||||
continue
|
||||
}
|
||||
relative, err := catalogBundlePath(internalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(release, relative)); err != nil || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("local ServerData catalog references a missing bundle: %s", relative)
|
||||
}
|
||||
remoteCount++
|
||||
}
|
||||
if remoteCount == 0 {
|
||||
return nil, errors.New("local ServerData catalog has no CDN bundle entries")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func decodeCatalog(raw []byte) (map[string]json.RawMessage, []string, error) {
|
||||
var document map[string]json.RawMessage
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateLocalCatalogRequiresEveryRemoteBundle(t *testing.T) {
|
||||
release := t.TempDir()
|
||||
path := filepath.Join("nested", "current.bundle")
|
||||
if err := os.MkdirAll(filepath.Join(release, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(release, path), []byte("bundle"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"m_InternalIds": []string{
|
||||
remoteCatalogPrefix + `StandaloneWindows64\HD\version\nested/current.bundle`,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateLocalCatalog(raw, release); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(release, path)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateLocalCatalog(raw, release); err == nil {
|
||||
t.Fatal("catalog with missing bundle unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBundlePathRejectsTraversal(t *testing.T) {
|
||||
if _, err := catalogBundlePath(remoteCatalogPrefix + `StandaloneWindows64\HD\version\..\escape.bundle`); err == nil {
|
||||
t.Fatal("catalog traversal path unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceCatalogReplacesExistingFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "catalog.json")
|
||||
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := replaceCatalog(path, []byte("new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "new" {
|
||||
t.Fatalf("catalog=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizeCatalogPreservesMetadataAndCreatesHardLink(t *testing.T) {
|
||||
release := t.TempDir()
|
||||
aa := t.TempDir()
|
||||
relative := filepath.Join("nested", "current.bundle")
|
||||
if err := os.MkdirAll(filepath.Join(release, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(release, relative)
|
||||
if err := os.WriteFile(source, []byte("bundle"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"m_BuildResultHash": "current-metadata",
|
||||
"m_InternalIds": []string{
|
||||
remoteCatalogPrefix + `StandaloneWindows64\HD\version\nested/current.bundle`,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
localized, err := localizeCatalog(raw, release, aa)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(localized, []byte(`"m_BuildResultHash":"current-metadata"`)) ||
|
||||
!bytes.Contains(localized, []byte(`Addressables.RuntimePath`)) {
|
||||
t.Fatalf("localized catalog=%s", localized)
|
||||
}
|
||||
sourceInfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
destinationInfo, err := os.Stat(filepath.Join(aa, relative))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(sourceInfo, destinationInfo) {
|
||||
t.Fatal("localized bundle is not a hard link to the selected release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynchronizePersistentCatalogBacksUpAndIsIdempotent(t *testing.T) {
|
||||
cache := t.TempDir()
|
||||
oldCatalog := []byte("old catalog")
|
||||
oldHash := []byte("old hash")
|
||||
if err := os.WriteFile(filepath.Join(cache, "catalog_alpha.json"), oldCatalog, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cache, "catalog_alpha.hash"), oldHash, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newCatalog := []byte("new catalog")
|
||||
newHash := []byte("new hash")
|
||||
if err := synchronizePersistentCatalog(cache, newCatalog, newHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := synchronizePersistentCatalog(cache, newCatalog, newHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string][]byte{
|
||||
"catalog_alpha.json": newCatalog,
|
||||
"catalog_alpha.hash": newHash,
|
||||
"catalog_alpha.json.bd2-before-local-sync": oldCatalog,
|
||||
"catalog_alpha.hash.bd2-before-local-sync": oldHash,
|
||||
} {
|
||||
got, err := os.ReadFile(filepath.Join(cache, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("%s=%q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
clientconfig "bd2server/internal/client/config"
|
||||
)
|
||||
|
||||
func testVersions() clientconfig.ReleaseVersions {
|
||||
return clientconfig.ReleaseVersions{
|
||||
GameVersion: "2.35.10", ClientVersion: "2.35.10+client.0.1.0", ServerVersion: "2.35.10+server.0.1.0", BundleVersion: "20260921135230", GameDataVersion: "20260923193640",
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRequiresGameFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Inspect(dir, testVersions()); err == nil {
|
||||
t.Fatal("accepted empty directory")
|
||||
}
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
status, err := Inspect(dir, testVersions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !status.Executable || !status.Resources || status.BepInEx {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRejectsUnsupportedClientVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.36.0\x00"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
status, err := Inspect(dir, testVersions())
|
||||
if err == nil || status.ClientVersion != "2.36.0" {
|
||||
t.Fatalf("status=%+v err=%v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectMacApp(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
app := filepath.Join(parent, "BrownDust II.app")
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(app, "Contents", "MacOS", "BrownDust II"): []byte("binary"),
|
||||
filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(app, "Contents", "Resources", "Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
|
||||
filepath.Join(parent, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
status, err := Inspect(app, testVersions())
|
||||
if err != nil || status.GameDirectory != app || status.ClientVersion != "2.35.10" || !status.BepInEx {
|
||||
t.Fatalf("status=%+v err=%v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchResourcePolicyRejectsServerVersionMismatch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(ResourcePolicy{
|
||||
Mode: clientconfig.CDNServer, ServerDataURL: "https://cdn.example/ServerData",
|
||||
GameDataURL: "https://cdn.example/GameData", BundleVersion: "wrong", GameDataVersion: "wrong",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{ServerOrigin: server.URL, CDNMode: clientconfig.CDNServer}, testVersions())
|
||||
if err == nil {
|
||||
t.Fatal("accepted mismatched server resource versions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchResourcePolicy(t *testing.T) {
|
||||
var method string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method = r.Method
|
||||
var request map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if request["cdn_mode"] != "server" {
|
||||
t.Errorf("request=%v", request)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(ResourcePolicy{
|
||||
Mode: clientconfig.CDNServer,
|
||||
ServerDataURL: "https://cdn.example/ServerData",
|
||||
GameDataURL: "https://cdn.example/GameData",
|
||||
BundleVersion: "20260921135230",
|
||||
GameDataVersion: "20260923193640",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
policy, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{
|
||||
ServerOrigin: server.URL,
|
||||
CDNMode: clientconfig.CDNServer,
|
||||
}, testVersions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if method != http.MethodPut || policy.Mode != clientconfig.CDNServer || policy.BundleVersion != "20260921135230" {
|
||||
t.Fatalf("method=%s policy=%+v", method, policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalResourcesDoNotContactServer(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, relative := range []string{
|
||||
filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.json"),
|
||||
filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.hash"),
|
||||
filepath.Join("GameData", "20260923193640", "release", "common-dbdata.info"),
|
||||
filepath.Join("GameData", "20260923193640", "release", "common-dbdata.bin"),
|
||||
} {
|
||||
path := filepath.Join(root, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("resource"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
|
||||
ServerOrigin: "http://127.0.0.1:8080",
|
||||
CDNMode: clientconfig.CDNLocal,
|
||||
LocalResourceDirectory: root,
|
||||
}, testVersions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if policy.Mode != clientconfig.CDNLocal || policy.LocalDirectory != root || policy.ServerDataURL == "" || policy.GameDataURL == "" {
|
||||
t.Fatalf("policy=%+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialDoesNotContactServer(t *testing.T) {
|
||||
policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
|
||||
ServerOrigin: "https://example.com",
|
||||
CDNMode: clientconfig.CDNOfficial,
|
||||
}, testVersions())
|
||||
if err != nil || policy.Mode != clientconfig.CDNOfficial {
|
||||
t.Fatalf("policy=%+v err=%v", policy, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePolicyLoopbackBypassesPlayerAndEnvironmentProxy(t *testing.T) {
|
||||
var proxyCalls atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxyCalls.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
t.Setenv("HTTP_PROXY", proxy.URL)
|
||||
t.Setenv("HTTPS_PROXY", proxy.URL)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(ResourcePolicy{Mode: clientconfig.CDNServer, ServerDataURL: "https://cdn.example/ServerData", GameDataURL: "https://cdn.example/GameData", BundleVersion: testVersions().BundleVersion, GameDataVersion: testVersions().GameDataVersion})
|
||||
}))
|
||||
defer server.Close()
|
||||
for _, configured := range []string{"", proxy.URL} {
|
||||
_, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ServerOrigin: server.URL, CDNMode: clientconfig.CDNServer, ProxyURL: configured}, testVersions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if proxyCalls.Load() != 0 {
|
||||
t.Fatal("loopback policy request used proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePolicyUsesExplicitProxyForRemoteOrigin(t *testing.T) {
|
||||
var proxyCalls atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodConnect || r.Host != "remote.invalid:443" {
|
||||
t.Errorf("unexpected proxy request: %s %s", r.Method, r.Host)
|
||||
}
|
||||
proxyCalls.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
_, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ServerOrigin: "https://remote.invalid", CDNMode: clientconfig.CDNServer, ProxyURL: proxy.URL}, testVersions())
|
||||
if err == nil || proxyCalls.Load() != 1 {
|
||||
t.Fatal("remote policy request did not use explicit HTTP proxy")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user