feat(all): add verified gacha flows and native client studio

This commit is contained in:
2026-09-30 23:49:00 +08:00
parent addc394cae
commit bf4f259bad
66 changed files with 2387 additions and 2351 deletions
+163 -85
View File
@@ -1,10 +1,10 @@
package app
import (
"encoding/json"
"io"
"context"
"errors"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -13,89 +13,183 @@ import (
clientconfig "bd2server/internal/client/config"
)
func TestIndexRequiresSessionAndServesEmbeddedStudio(t *testing.T) {
h := &handler{token: "test-session", origin: "http://127.0.0.1"}
server := httptest.NewServer(h.routes())
defer server.Close()
response, err := http.Get(server.URL + "/")
func TestEmbeddedStudioUsesNativeBindings(t *testing.T) {
page, err := fs.ReadFile(webFS, "web/index.html")
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusNotFound {
t.Fatalf("without session status=%d", response.StatusCode)
}
response, err = http.Get(server.URL + "/?session=test-session")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
buffer, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
page := string(buffer)
text := string(page)
for _, marker := range []string{
"BD2 Client Studio", "test-session", `name="bd2-platform"`,
"BD2 Client Studio", "window.go.app.Studio", "bridge().Initialize()",
`id="directoryScene"`, `id="serverScene"`, `id="deskScene"`,
`id="gameDir"`, `id="origin"`, `id="patch"`, `id="install"`, `id="launch"`,
`id="patch"`, `id="install"`, `id="launch"`,
`value="official"`, `value="local"`, `value="server"`,
`id="localResourceDir"`, `id="browseResources"`,
`Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Macau`, `Asia/Taipei`,
`const zhCN=CHINA_TIME_ZONES.has(detectedTimeZone)`,
"opening-curtain", "is-entering", "@keyframes reveal", "prefers-reduced-motion",
"prefers-reduced-motion",
"opening-curtain", "is-entering", "@keyframes reveal", "bridge().Quit()",
"Asia/Shanghai", "Asia/Hong_Kong", "Asia/Macau", "Asia/Taipei",
} {
if !strings.Contains(page, marker) {
t.Errorf("page lacks %q", marker)
if !strings.Contains(text, marker) {
t.Errorf("embedded desktop interface lacks %q", marker)
}
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("Cache-Control") != "no-store" {
t.Fatalf("security headers=%v", response.Header)
for _, obsolete := range []string{"fetch(", "/api/", "bd2-session", "X-BD2-Session", "{{.", "window_darwin.js"} {
if strings.Contains(text, obsolete) {
t.Errorf("embedded desktop interface retains obsolete browser bridge %q", obsolete)
}
}
}
func TestAPIRejectsMalformedAndTrailingJSON(t *testing.T) {
h := &handler{token: "test-session", origin: "http://local.invalid", versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}
for name, body := range map[string]string{
"malformed": `{`,
"trailing": `{}` + `{}`,
"unknown": `{"unexpected":true}`,
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(body))
request.Header.Set("X-BD2-Session", "test-session")
request.Header.Set("Origin", "http://local.invalid")
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
})
func TestStudioInitializeLoadsSavedSettings(t *testing.T) {
dir := makeTestClient(t)
studio := NewStudio(Options{InitialGameDir: dir, Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, 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" {
t.Fatalf("state=%+v", state)
}
}
func TestAPIRejectsMissingTokenAndForeignOrigin(t *testing.T) {
h := &handler{token: "test-session", origin: "http://local.invalid", browse: func(string) (string, error) { return "", nil }}
for name, values := range map[string][2]string{
"missing token": {"", "http://local.invalid"},
"foreign origin": {"test-session", "https://attacker.invalid"},
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/browse", strings.NewReader("{}"))
request.Header.Set("X-BD2-Session", values[0])
request.Header.Set("Origin", values[1])
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
})
func TestStudioSaveRemembersOnlyValidatedSettings(t *testing.T) {
dir := makeTestClient(t)
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{ClientVersion: "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 TestInspectAPI(t *testing.T) {
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{ClientVersion: "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"),
@@ -109,21 +203,5 @@ func TestInspectAPI(t *testing.T) {
t.Fatal(err)
}
}
h := &handler{token: "test-session", origin: "http://local.invalid", versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}
body, _ := json.Marshal(request{GameDirectory: dir})
req := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(string(body)))
req.Header.Set("X-BD2-Session", "test-session")
req.Header.Set("Origin", "http://local.invalid")
recorder := httptest.NewRecorder()
h.routes().ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var result response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatalf("response=%+v", result)
}
return dir
}