mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
fix(bat-api): 完成 issue #19 同机 live 联调
This commit is contained in:
@@ -66,6 +66,29 @@ func TestLoadIndexFromResourceRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseSummaryRequiresCompleteManifest(t *testing.T) {
|
||||
idx := &ReleaseIndex{
|
||||
ResourceRoot: "/tmp/release",
|
||||
Entries: []ResourceEntry{
|
||||
{RelativePath: "host/ready.bin", Present: true, SizeMatch: true},
|
||||
{RelativePath: "host/missing.bin", Present: false, SizeMatch: false},
|
||||
},
|
||||
MissingOnDisk: []string{"host/missing.bin"},
|
||||
byRel: map[string]int{},
|
||||
}
|
||||
summary := idx.Summary()
|
||||
if summary.PresentCount != 1 || summary.MissingCount != 1 || summary.Ready {
|
||||
t.Fatalf("partial release summary=%+v", summary)
|
||||
}
|
||||
|
||||
idx.Entries[1] = ResourceEntry{RelativePath: "host/missing.bin", Present: true, SizeMatch: false}
|
||||
idx.MissingOnDisk = []string{"host/missing.bin#size_mismatch"}
|
||||
summary = idx.Summary()
|
||||
if summary.Ready {
|
||||
t.Fatalf("size-mismatched release summary=%+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCDNPathRejectsEscape(t *testing.T) {
|
||||
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
@@ -124,6 +147,17 @@ func TestCDNServesIndexedFile(t *testing.T) {
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing status=%d", rr.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/prod-clientpatch.bluearchiveyostar.com/%2e%2e/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json",
|
||||
nil,
|
||||
)
|
||||
rr = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("dot-segment status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDNSupportsRangeHeadAndConditionalRequests(t *testing.T) {
|
||||
@@ -524,6 +558,7 @@ type fakeBackend struct {
|
||||
status *backendrpc.DaemonStatusReport
|
||||
doctor *backendrpc.DoctorReport
|
||||
catalog json.RawMessage
|
||||
resource *backendrpc.ResourceState
|
||||
manifest *backendrpc.ResourceManifestPage
|
||||
}
|
||||
|
||||
@@ -539,6 +574,9 @@ func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorRepor
|
||||
return f.doctor, nil
|
||||
}
|
||||
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
|
||||
if f.resource != nil {
|
||||
return f.resource, nil
|
||||
}
|
||||
return &backendrpc.ResourceState{}, nil
|
||||
}
|
||||
func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
@@ -780,6 +818,112 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshClearsPublishedIndexWhenCurrentReleaseDisappears(t *testing.T) {
|
||||
root := fixtureRoot(t)
|
||||
info, err := os.Stat(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
size := uint64(info.Size())
|
||||
catalogRaw, err := json.Marshal(map[string]any{
|
||||
"available": true,
|
||||
"status": "published",
|
||||
"version": map[string]any{
|
||||
"id": "v1",
|
||||
"resource_root": root,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &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: &size,
|
||||
BLAKE3: strings.Repeat("0", 64),
|
||||
}},
|
||||
},
|
||||
}
|
||||
cfg := DefaultConfig()
|
||||
cfg.RefreshInterval = 0
|
||||
if err := cfg.Normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewServer(cfg, backend, log.New(io.Discard, "", 0))
|
||||
if err := s.Refresh(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initial := s.index()
|
||||
if initial == nil {
|
||||
t.Fatal("initial index missing")
|
||||
}
|
||||
if summary := initial.Summary(); !summary.Ready || summary.ResourceRoot != root {
|
||||
t.Fatalf("initial summary=%+v", summary)
|
||||
}
|
||||
|
||||
backend.catalog = json.RawMessage(`{"available":false,"status":"waiting"}`)
|
||||
outputRoot := filepath.Join(t.TempDir(), "official")
|
||||
backend.resource = &backendrpc.ResourceState{ResourceOutputRoot: &outputRoot}
|
||||
if err := s.Refresh(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := s.index()
|
||||
if current == nil {
|
||||
t.Fatal("current index missing")
|
||||
}
|
||||
summary := current.Summary()
|
||||
if summary.Ready || summary.ResourceRoot != "" {
|
||||
t.Fatalf("stale summary=%+v", summary)
|
||||
}
|
||||
if len(s.meta.Warnings) == 0 {
|
||||
t.Fatal("missing disappearance warning")
|
||||
}
|
||||
|
||||
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 TestDiscoverTreatsCatalogUnavailableAsNoRelease(t *testing.T) {
|
||||
fixture := fixtureRoot(t)
|
||||
outputRoot := t.TempDir()
|
||||
if err := os.Symlink(fixture, filepath.Join(outputRoot, "current")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &fakeBackend{
|
||||
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
|
||||
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
|
||||
catalog: json.RawMessage(`{"available":false,"status":"unavailable"}`),
|
||||
resource: &backendrpc.ResourceState{ResourceOutputRoot: &outputRoot},
|
||||
}
|
||||
|
||||
result, err := DiscoverAndIndex(context.Background(), backend, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Index == nil {
|
||||
t.Fatal("index missing")
|
||||
}
|
||||
summary := result.Index.Summary()
|
||||
if summary.Ready || summary.ResourceRoot != "" || summary.EntryCount != 0 {
|
||||
t.Fatalf("catalog unavailable summary=%+v", summary)
|
||||
}
|
||||
if len(result.Warnings) == 0 {
|
||||
t.Fatal("missing catalog unavailable warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvFileDoesNotOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -15,9 +16,35 @@ func (s *Server) wrapHandler(next http.Handler) http.Handler {
|
||||
handler = s.rateLimitMiddleware(handler)
|
||||
handler = s.securityHeadersMiddleware(handler)
|
||||
handler = s.accessLogMiddleware(handler)
|
||||
// Check before ServeMux can clean dot segments and route an escaped path
|
||||
// to a different host-shaped endpoint.
|
||||
handler = rejectDotSegmentsMiddleware(handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
func rejectDotSegmentsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if hasDotPathSegment(r.URL.Path) || (r.URL.RawPath != "" && hasDotPathSegment(r.URL.RawPath)) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func hasDotPathSegment(rawPath string) bool {
|
||||
decoded, err := url.PathUnescape(rawPath)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, segment := range strings.Split(decoded, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -106,7 +106,10 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
|
||||
EntryCount: len(idx.Entries),
|
||||
PresentCount: present,
|
||||
MissingCount: len(idx.MissingOnDisk),
|
||||
Ready: idx.ResourceRoot != "" && present > 0,
|
||||
// A release is distributable only when every manifest entry is present
|
||||
// and has the expected size. Serving a partial release can leave clients
|
||||
// with an apparently valid bootstrap and an unrecoverable download set.
|
||||
Ready: idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"bat-api/internal/backendrpc"
|
||||
@@ -206,16 +207,22 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
||||
// Catalog / resource discovery
|
||||
var snapshot *SnapshotSummary
|
||||
var resourceRoot string
|
||||
catalogAvailabilityKnown := false
|
||||
catalogAvailable := false
|
||||
|
||||
if raw, err := backend.CatalogStatus(ctx); err != nil {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf("catalog.status: %v", err))
|
||||
} else {
|
||||
catalogAvailable, catalogAvailabilityKnown = parseCatalogAvailability(raw)
|
||||
snap, root, ok := parseCatalogStatus(raw)
|
||||
if ok {
|
||||
snapshot = snap
|
||||
resourceRoot = root
|
||||
}
|
||||
}
|
||||
if catalogAvailabilityKnown && !catalogAvailable {
|
||||
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
|
||||
}
|
||||
|
||||
if resourceRoot == "" {
|
||||
if state, err := backend.ResourceState(ctx); err != nil {
|
||||
@@ -243,12 +250,32 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if resourceRootOverride == "" {
|
||||
info, statErr := os.Stat(resourceRoot)
|
||||
if statErr != nil || !info.IsDir() {
|
||||
warning := fmt.Sprintf("published resource root is unavailable: %s", resourceRoot)
|
||||
if statErr != nil {
|
||||
warning = fmt.Sprintf("%s (%v)", warning, statErr)
|
||||
} else {
|
||||
warning = fmt.Sprintf("%s (not a directory)", warning)
|
||||
}
|
||||
return emptyRPCResult(out, snapshot, warning), nil
|
||||
}
|
||||
}
|
||||
|
||||
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend)
|
||||
if err != nil {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
|
||||
// Fallback: load local manifest file under root.
|
||||
idx, loadErr := LoadIndexFromResourceRoot(resourceRoot)
|
||||
if loadErr != nil {
|
||||
if resourceRootOverride == "" {
|
||||
return emptyRPCResult(
|
||||
out,
|
||||
snapshot,
|
||||
fmt.Sprintf("published release cannot be indexed: %v", loadErr),
|
||||
), nil
|
||||
}
|
||||
return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr)
|
||||
}
|
||||
idx.Source = "rpc+local_manifest"
|
||||
@@ -279,6 +306,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
||||
entries,
|
||||
)
|
||||
if err != nil {
|
||||
if resourceRootOverride == "" {
|
||||
return emptyRPCResult(
|
||||
out,
|
||||
snapshot,
|
||||
fmt.Sprintf("published release failed local validation: %v", err),
|
||||
), nil
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
out.ResourceRoot = idx.ResourceRoot
|
||||
@@ -287,6 +321,22 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
|
||||
out.ResourceRoot = ""
|
||||
out.Snapshot = snapshot
|
||||
out.Index = &ReleaseIndex{
|
||||
Source: "rpc",
|
||||
RPCAvailable: true,
|
||||
DoctorHealthy: out.DoctorHealthy,
|
||||
Snapshot: snapshot,
|
||||
byRel: map[string]int{},
|
||||
}
|
||||
if warning != "" {
|
||||
out.Warnings = append(out.Warnings, warning)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, "", false
|
||||
@@ -333,6 +383,19 @@ func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
|
||||
return snap, root, true
|
||||
}
|
||||
|
||||
func parseCatalogAvailability(raw json.RawMessage) (bool, bool) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return false, false
|
||||
}
|
||||
var payload struct {
|
||||
Available *bool `json:"available"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || payload.Available == nil {
|
||||
return false, false
|
||||
}
|
||||
return *payload.Available, true
|
||||
}
|
||||
|
||||
func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) {
|
||||
const pageSize = 500
|
||||
offset := 0
|
||||
|
||||
Reference in New Issue
Block a user