feat(all): add verified gacha flows and native client studio
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user