Files
nyaKazuha 99355effe4
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
fix(release): 完成分发证明代际绑定与质量门禁收口
2026-09-15 21:13:52 +08:00

2530 lines
96 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 copyFixtureRoot(t *testing.T) string {
t.Helper()
source := fixtureRoot(t)
target := filepath.Join(t.TempDir(), "release")
if err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
destination := filepath.Join(target, rel)
if info.IsDir() {
return os.MkdirAll(destination, info.Mode().Perm())
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(destination, data, info.Mode().Perm())
}); err != nil {
t.Fatal(err)
}
return target
}
func fixtureRPCBackend(t *testing.T, root string) *fakeBackend {
t.Helper()
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
manifestEntries := make([]backendrpc.ResourceManifestEntry, 0, len(idx.Entries))
for _, entry := range idx.Entries {
size := entry.Bytes
manifestEntries = append(manifestEntries, backendrpc.ResourceManifestEntry{
URL: entry.URL,
Destination: entry.RelativePath,
Bytes: &size,
BLAKE3: entry.BLAKE3,
})
}
catalog, err := json.Marshal(map[string]any{
"available": true,
"status": "published",
"version": map[string]any{
"id": "official-fixture",
"resource_root": root,
},
})
if err != nil {
t.Fatal(err)
}
return &fakeBackend{
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
catalog: catalog,
manifest: &backendrpc.ResourceManifestPage{
Available: true,
Channel: "official",
ReleaseID: "official-fixture",
ResourceRoot: root,
ManifestVersion: 1,
PublicationIdentity: "fixture-publication-v1",
MappingIdentity: "fixture-mapping-v1",
ManifestIdentity: "fixture-manifest-v1",
Generation: 1,
TotalEntries: len(manifestEntries),
Entries: manifestEntries,
},
releaseStatus: &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "valid",
}},
},
}
}
const fixtureCurrentCDNPath = "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
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 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 TestCurrentCDNRequiresRustWholeReleaseHealth(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, root string)
}{
{
name: "missing entry",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.Remove(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash")); err != nil {
t.Fatal(err)
}
},
},
{
name: "size mismatch",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("too-large"), 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "same-size corruption",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
t.Fatal(err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := copyFixtureRoot(t)
tt.mutate(t, root)
backend := fixtureRPCBackend(t, root)
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "blocked",
StatusCode: "distribution.blocked",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: false,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "invalid",
Diagnostics: []string{"fixture integrity failure"},
}},
}
cfg := DefaultConfig()
cfg.RequireIndexed = false
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
summary := server.index().Summary()
if summary.Ready || summary.Distribution.Ready ||
summary.Distribution.StatusCode != "distribution.blocked" {
t.Fatalf("summary=%+v", summary)
}
for _, path := range []string{"/readyz", "/v1/bootstrap"} {
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
}
}
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("current CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("ETag") != "" || recorder.Header().Get("Cache-Control") != "" {
t.Fatalf("unhealthy CDN headers=%v", recorder.Header())
}
})
}
}
func TestRefreshCurrentReleaseHealthTransitionsAndClearsFailure(t *testing.T) {
root := copyFixtureRoot(t)
backend := fixtureRPCBackend(t, root)
cfg := DefaultConfig()
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if !server.index().Summary().Ready {
t.Fatalf("initial release is not ready: %+v", server.index().Summary())
}
get := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
return recorder
}
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("healthy CDN status=%d body=%q", recorder.Code, recorder.Body.String())
}
// The bytes stay the same size, but Rust's next release.status result
// revokes whole-release distribution authorization.
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
t.Fatal(err)
}
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "blocked",
StatusCode: "distribution.blocked",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "invalid",
}},
}
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if server.index().Summary().Ready || get().Code != http.StatusServiceUnavailable {
t.Fatalf("unhealthy refresh summary=%+v", server.index().Summary())
}
backend.releaseStatusErr = errors.New("release.status transport failure")
if err := server.Refresh(context.Background()); err == nil {
t.Fatal("expected refresh failure")
}
if summary := server.index().Summary(); summary.Ready || summary.ResourceRoot != "" {
t.Fatalf("failed refresh retained snapshot=%+v", summary)
}
healthRecorder := httptest.NewRecorder()
server.Handler().ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
var health map[string]any
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
refresh := health["refresh"].(map[string]any)
if refresh["last_error"] == "" {
t.Fatalf("refresh diagnostics=%v", refresh)
}
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("1234567890"), 0o644); err != nil {
t.Fatal(err)
}
backend.releaseStatusErr = nil
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "valid",
}},
}
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if summary := server.index().Summary(); !summary.Ready || !summary.Distribution.Ready {
t.Fatalf("recovered summary=%+v", summary)
}
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("recovered CDN status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
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)
}
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) {
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
releaseStatusCalls int
attestationCalls int
status *backendrpc.DaemonStatusReport
doctor *backendrpc.DoctorReport
releaseStatus *backendrpc.ReleaseStatusReport
releaseStatusErr error
attestation *backendrpc.DistributionAttestation
catalog json.RawMessage
resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage
manifestParams []backendrpc.ResourceManifestParams
daemonLogs *backendrpc.LogsReport
taskList *backendrpc.TaskList
taskStatus *backendrpc.TaskRecord
taskLogs *backendrpc.TaskLogs
taskCancel *backendrpc.TaskCancelResult
}
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++
return f.doctor, nil
}
func (f *fakeBackend) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
f.releaseStatusCalls++
if f.releaseStatusErr != nil {
return nil, f.releaseStatusErr
}
if f.releaseStatus != nil {
return f.releaseStatus, nil
}
return &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
}, nil
}
func (f *fakeBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) {
f.attestationCalls++
// Keep the older test fixture controls useful while discovery moves to the
// lightweight RPC: releaseStatus still supplies the desired ready/blocked
// state unless a test explicitly installs an attestation.
if f.attestation != nil {
return f.attestation, nil
}
f.releaseStatusCalls++
if f.releaseStatusErr != nil {
return nil, f.releaseStatusErr
}
releaseStatus := f.releaseStatus
if releaseStatus == nil {
releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialDistributionReady: true,
}
}
releaseID := "fixture-release"
resourceRoot := ""
if f.manifest != nil {
releaseID = f.manifest.ReleaseID
resourceRoot = f.manifest.ResourceRoot
}
var catalog struct {
Version struct {
ID string `json:"id"`
ResourceRoot string `json:"resource_root"`
} `json:"version"`
}
if json.Unmarshal(f.catalog, &catalog) == nil {
if catalog.Version.ID != "" {
releaseID = catalog.Version.ID
}
if catalog.Version.ResourceRoot != "" {
resourceRoot = catalog.Version.ResourceRoot
}
}
if releaseStatus.OfficialCurrentReleaseID != "" {
releaseID = releaseStatus.OfficialCurrentReleaseID
}
integrity := "verified"
for _, release := range releaseStatus.Releases {
if release.Channel == "official" && release.Current {
integrity = release.DistributionIntegrityStatus
break
}
}
if integrity == "valid" {
integrity = "verified"
}
verifiedAt := uint64(time.Now().Unix())
return &backendrpc.DistributionAttestation{
Available: true,
Channel: "official",
ReleaseID: releaseID,
ResourceRoot: resourceRoot,
PublicationIdentity: "fixture-publication-v1",
MappingIdentity: "fixture-mapping-v1",
ManifestIdentity: "fixture-manifest-v1",
EntryCount: func() int {
if f.manifest == nil {
return 0
}
return len(f.manifest.Entries)
}(),
IntegrityStatus: integrity,
Status: releaseStatus.Status,
StatusCode: releaseStatus.StatusCode,
Ready: releaseStatus.OfficialDistributionReady,
VerificationGeneration: 1,
VerifiedAt: &verifiedAt,
MaxAgeSeconds: 7260,
}, 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) {
return f.catalog, nil
}
func (f *fakeBackend) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
if f.manifest == nil {
return nil, nil
}
f.manifestParams = append(f.manifestParams, params)
page := *f.manifest
if page.Channel == "" {
page.Channel = "official"
}
if page.ReleaseID == "" {
page.ReleaseID = params.ReleaseID
}
if page.ResourceRoot == "" {
page.ResourceRoot = f.manifest.ResourceRoot
}
if page.PublicationIdentity == "" {
page.PublicationIdentity = params.ExpectedPublicationIdentity
}
if page.ManifestIdentity == "" {
page.ManifestIdentity = params.ExpectedManifestIdentity
}
if page.MappingIdentity == "" {
page.MappingIdentity = "fixture-mapping-v1"
}
if page.Generation == 0 {
page.Generation = 1
}
page.Offset = params.Offset
page.Limit = params.Limit
return &page, nil
}
func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
if f.daemonLogs != nil {
return f.daemonLogs, nil
}
return &backendrpc.LogsReport{Command: "logs", Status: "ok", Message: "ok", LogPath: "/tmp/bat-daemon.log", Exists: true, Empty: false, Bytes: 1, TotalLines: 1, ReturnedLines: 1, Content: "line-1\n"}, nil
}
func (f *fakeBackend) TaskList(ctx context.Context) (*backendrpc.TaskList, error) {
if f.taskList != nil {
return f.taskList, nil
}
return &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{}}, nil
}
func (f *fakeBackend) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) {
if f.taskStatus != nil {
out := *f.taskStatus
out.ID = taskID
return &out, nil
}
return &backendrpc.TaskRecord{ID: taskID, Kind: "resource.sync", Status: "running"}, nil
}
func (f *fakeBackend) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) {
if f.taskLogs != nil {
out := *f.taskLogs
out.TaskID = taskID
return &out, nil
}
return &backendrpc.TaskLogs{TaskID: taskID, Lines: []string{"task-log-line-1"}}, nil
}
func (f *fakeBackend) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) {
if f.taskCancel != nil {
out := *f.taskCancel
out.TaskID = taskID
return &out, nil
}
return &backendrpc.TaskCancelResult{TaskID: taskID, CancelRequested: true, Note: "cancel requested"}, nil
}
type controlBackend struct {
*fakeBackend
calls []string
parseTextUnitQueries []backendrpc.TextUnitQueryParams
parseErrorQueries []backendrpc.TextUnitQueryParams
translationTaskUpdates []backendrpc.TranslationTaskUpdateParams
translationTaskListParams []backendrpc.TranslationTaskListParams
translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams
translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams
translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams
glossarySummaryParams []backendrpc.GlossarySummaryParams
glossaryQueryParams []backendrpc.GlossaryQueryParams
glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams
glossaryMutationParams []backendrpc.GlossaryTermMutationParams
glossaryReviewParams []backendrpc.GlossaryReviewParams
glossaryDeleteParams []backendrpc.GlossaryDeleteParams
localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams
}
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
b.calls = append(b.calls, "daemon.reload")
return &backendrpc.Ack{Command: "reload", Status: "accepted"}, nil
}
func (b *controlBackend) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
b.calls = append(b.calls, "daemon.restart")
return &backendrpc.Ack{Command: "restart", Status: "accepted"}, nil
}
func (b *controlBackend) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) {
b.calls = append(b.calls, "daemon.refresh")
return &backendrpc.Ack{Command: "refresh", Status: "accepted", Force: &force}, nil
}
func (b *controlBackend) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
b.calls = append(b.calls, "resource.sync")
return &backendrpc.TaskAccepted{TaskID: "task-sync-1", Kind: "resource.sync"}, nil
}
func (b *controlBackend) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) {
b.calls = append(b.calls, "resource.verify")
return &backendrpc.TaskAccepted{TaskID: "task-verify-1", Kind: "resource.verify"}, nil
}
func (b *controlBackend) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) {
b.calls = append(b.calls, "resource.repair")
return &backendrpc.TaskAccepted{TaskID: "task-repair-1", Kind: "resource.repair"}, nil
}
func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
b.calls = append(b.calls, "catalog.refresh")
return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil
}
func (b *controlBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
b.calls = append(b.calls, "daemon.doctor")
if b.fakeBackend != nil {
return b.fakeBackend.DaemonDoctor(ctx)
}
return &backendrpc.DoctorReport{Command: "doctor", Status: "ok", Message: "healthy", Healthy: true}, nil
}
func (b *controlBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
b.calls = append(b.calls, "daemon.logs")
if b.fakeBackend != nil {
return b.fakeBackend.DaemonLogs(ctx, tail)
}
return &backendrpc.LogsReport{Command: "logs", Status: "ok", Message: "ok", LogPath: "/tmp/bat-daemon.log", Exists: true, Empty: false, Bytes: 1, TotalLines: 1, ReturnedLines: 1, Content: "line-1\n"}, nil
}
func (b *controlBackend) TaskList(ctx context.Context) (*backendrpc.TaskList, error) {
b.calls = append(b.calls, "task.list")
if b.fakeBackend != nil {
return b.fakeBackend.TaskList(ctx)
}
return &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{}}, nil
}
func (b *controlBackend) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) {
b.calls = append(b.calls, "task.status")
if b.fakeBackend != nil {
return b.fakeBackend.TaskStatus(ctx, taskID)
}
return &backendrpc.TaskRecord{ID: taskID, Kind: "resource.sync", Status: "running"}, nil
}
func (b *controlBackend) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) {
b.calls = append(b.calls, "task.logs")
if b.fakeBackend != nil {
return b.fakeBackend.TaskLogs(ctx, taskID)
}
return &backendrpc.TaskLogs{TaskID: taskID, Lines: []string{"task-log-line-1"}}, nil
}
func (b *controlBackend) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) {
b.calls = append(b.calls, "task.cancel")
if b.fakeBackend != nil {
return b.fakeBackend.TaskCancel(ctx, taskID)
}
return &backendrpc.TaskCancelResult{TaskID: taskID, CancelRequested: true, Note: "cancel requested"}, nil
}
func (b *controlBackend) ParseStatus(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "parse.status")
return json.RawMessage(`{"available":true,"status":"parsed"}`), nil
}
func (b *controlBackend) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
b.calls = append(b.calls, "parse.text_units")
b.parseTextUnitQueries = append(b.parseTextUnitQueries, query)
return json.RawMessage(`{"available":true,"entries":[{"id":"direct:a#unit:0","destination":"Bundle/a.bundle","source_text":"source","field_path":"Scenario.Message"}]}`), nil
}
func (b *controlBackend) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
b.calls = append(b.calls, "parse.errors")
b.parseErrorQueries = append(b.parseErrorQueries, query)
return json.RawMessage(`{"available":true,"entries":[]}`), nil
}
func (b *controlBackend) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) {
b.calls = append(b.calls, "translation.task.update")
b.translationTaskUpdates = append(b.translationTaskUpdates, params)
return json.RawMessage(`{"task_status":"` + params.Status + `"}`), nil
}
func (b *controlBackend) TranslationTasks(ctx context.Context, params backendrpc.TranslationTaskListParams) (json.RawMessage, error) {
b.calls = append(b.calls, "translation.tasks")
b.translationTaskListParams = append(b.translationTaskListParams, params)
return json.RawMessage(`{"tasks":[{"task_id":"textunit/v-current/Scenario","worker_status":"failed","failure_reason":"provider rejected payload"}]}`), nil
}
func (b *controlBackend) TranslationHandoff(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "translation.handoff")
return json.RawMessage(`{"jobs":[],"provider_runs":[]}`), nil
}
func (b *controlBackend) TranslationWorkerRun(ctx context.Context, params backendrpc.TranslationWorkerRunParams) (*backendrpc.TranslationWorkerRunResult, error) {
b.calls = append(b.calls, "translation.worker.run")
concurrency := uint64(8)
if params.Concurrency != nil {
concurrency = *params.Concurrency
}
return &backendrpc.TranslationWorkerRunResult{
TaskID: "task-translation-worker-1",
Kind: "translation.worker.run",
Worker: backendrpc.TranslationWorkerConfig{
Provider: params.Provider,
FixturePath: params.FixturePath,
Concurrency: concurrency,
MaxAttempts: 3,
LeaseSeconds: 300,
RetryBackoffSeconds: 5,
WorkerID: params.WorkerID,
},
}, nil
}
func (b *controlBackend) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "translation.proofread")
return json.RawMessage(`{"translation_workflow_status":"manual_proofreading"}`), nil
}
func (b *controlBackend) TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) {
b.calls = append(b.calls, "translation.memory.summary")
b.translationMemorySummaryParams = append(b.translationMemorySummaryParams, params)
schemaVersion := uint64(1)
return &backendrpc.TranslationMemorySummaryReport{
Available: true,
Path: params.TranslationMemoryPath,
SchemaVersion: &schemaVersion,
Summary: &backendrpc.TranslationMemorySummary{
SchemaVersion: schemaVersion,
RecordCount: 2,
TrustedCount: 1,
CandidateCount: 1,
SupersededCount: 0,
RejectedCount: 0,
},
}, nil
}
func (b *controlBackend) TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error) {
b.calls = append(b.calls, "translation.memory.query")
b.translationMemoryQueryParams = append(b.translationMemoryQueryParams, params)
return &backendrpc.TranslationMemoryQueryReport{
Available: true,
Path: params.TranslationMemoryPath,
SourceText: params.SourceText,
SourceContext: params.SourceContext,
Matches: []backendrpc.TranslationMemoryMatch{},
}, nil
}
func (b *controlBackend) TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error) {
b.calls = append(b.calls, "translation.memory.confirm")
b.translationMemoryConfirmParams = append(b.translationMemoryConfirmParams, params)
return &backendrpc.TranslationMemoryConfirmReport{
Available: true,
Path: params.TranslationMemoryPath,
Entry: backendrpc.TranslationMemoryEntry{
RecordID: params.RecordID,
TranslationSourceKind: "provider",
TrustStatus: "trusted",
},
}, nil
}
func (b *controlBackend) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
b.calls = append(b.calls, "translation.glossary.summary")
b.glossarySummaryParams = append(b.glossarySummaryParams, params)
schemaVersion := uint64(1)
return &backendrpc.GlossarySummaryReport{
Available: true,
Path: params.GlossaryPath,
SchemaVersion: &schemaVersion,
Summary: &backendrpc.GlossarySummary{
SchemaVersion: schemaVersion,
TermCount: 2,
ApprovedCount: 1,
DraftCount: 1,
},
}, nil
}
func (b *controlBackend) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) {
b.calls = append(b.calls, "translation.glossary.query")
b.glossaryQueryParams = append(b.glossaryQueryParams, params)
return &backendrpc.GlossaryQueryReport{
Available: true,
Path: params.GlossaryPath,
Terms: []backendrpc.GlossaryTerm{{
TermID: "term-sensei",
GlossaryTermSnapshot: backendrpc.GlossaryTermSnapshot{
SourceTerm: "Sensei",
RecommendedTranslation: "老师",
Priority: 10,
},
ReviewStatus: backendrpc.GlossaryStatusApproved,
Source: backendrpc.GlossarySourceRecord{
SourceKind: "manual",
ObservedUnixSeconds: 100,
},
}},
}, nil
}
func (b *controlBackend) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) {
b.calls = append(b.calls, "translation.glossary.diagnose")
b.glossaryDiagnoseParams = append(b.glossaryDiagnoseParams, params)
return &backendrpc.GlossaryDiagnoseReport{
Available: true,
Path: params.GlossaryPath,
SourceText: params.SourceText,
Context: params.Context,
Evaluation: json.RawMessage(`{"constraints":[],"diagnostics":[],"blocked":false}`),
}, nil
}
func (b *controlBackend) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.add")
b.glossaryMutationParams = append(b.glossaryMutationParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.update")
b.glossaryMutationParams = append(b.glossaryMutationParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.approve")
b.glossaryReviewParams = append(b.glossaryReviewParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.deprecate")
b.glossaryReviewParams = append(b.glossaryReviewParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.delete")
b.glossaryDeleteParams = append(b.glossaryDeleteParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "localized.status")
return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil
}
func (b *controlBackend) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) {
b.calls = append(b.calls, "localized.publish")
b.localizedPublishParams = append(b.localizedPublishParams, params)
return json.RawMessage(`{"status":"published","localized_release_id":"localized-1"}`), nil
}
func (b *controlBackend) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) {
b.calls = append(b.calls, "localized.rollback")
b.localizedRollbackParams = append(b.localizedRollbackParams, params)
return json.RawMessage(`{"status":"rolled_back","rolled_back_release_id":"localized-1"}`), nil
}
type scheduleBackend struct {
*controlBackend
scheduleCalls []string
scheduleListParams []backendrpc.ScheduleListParams
scheduleRaw json.RawMessage
}
func (b *scheduleBackend) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.list")
b.scheduleListParams = append(b.scheduleListParams, params)
return b.scheduleRaw, nil
}
func (b *scheduleBackend) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.add")
return b.scheduleRaw, nil
}
func (b *scheduleBackend) ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.update")
return b.scheduleRaw, nil
}
func (b *scheduleBackend) ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.remove")
return b.scheduleRaw, nil
}
func (b *scheduleBackend) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.run")
return b.scheduleRaw, nil
}
func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "schedule-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &scheduleBackend{
controlBackend: &controlBackend{fakeBackend: &fakeBackend{}},
scheduleRaw: json.RawMessage(`{"command":"schedule-list","status":"ok","schedules":[]}`),
}
s := NewServer(cfg, backend, nil)
request := httptest.NewRequest(http.MethodGet, "/admin/schedules?id=nightly-pull&group=res&enabled=true", nil)
request.Header.Set("Authorization", "Bearer schedule-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !json.Valid(recorder.Body.Bytes()) {
t.Fatalf("list body is not JSON: %s", recorder.Body.String())
}
if len(backend.scheduleListParams) != 1 ||
backend.scheduleListParams[0].ID != "nightly-pull" ||
backend.scheduleListParams[0].Group != "res" ||
backend.scheduleListParams[0].Enabled == nil ||
!*backend.scheduleListParams[0].Enabled {
t.Fatalf("list params=%#v", backend.scheduleListParams)
}
request = httptest.NewRequest(
http.MethodPost,
"/admin/control/schedule-update",
strings.NewReader(`{"id":"nightly-pull","every_seconds":3600,"clear_every":false}`),
)
request.Header.Set("Authorization", "Bearer schedule-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("update status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.scheduleCalls) != 2 ||
backend.scheduleCalls[0] != "schedule.list" ||
backend.scheduleCalls[1] != "schedule.update" {
t.Fatalf("schedule calls=%v", backend.scheduleCalls)
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/schedule-run", nil)
request.Header.Set("Authorization", "Bearer schedule-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("run status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.scheduleCalls) != 3 || backend.scheduleCalls[2] != "schedule.run" {
t.Fatalf("schedule calls=%v", backend.scheduleCalls)
}
request = httptest.NewRequest(http.MethodGet, "/admin/schedules", nil)
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated list status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/schedules?enabled=invalid", nil)
request.Header.Set("Authorization", "Bearer schedule-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid query status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "translation-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
request := httptest.NewRequest(
http.MethodGet,
"/admin/translation/tasks?offset=10&limit=25&task_id=textunit%2Fv-current%2FScenario&release_id=v-current&destination=MediaResources%2FGameData%2FScenario.zip&path_pattern=Scenario&archive_entry=ScenarioExcelTable.json&status=queued_offline&worker_status=failed&parse_status=ok&format=json&has_reason=true&has_failure_reason=false",
nil,
)
request.Header.Set("Authorization", "Bearer translation-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("tasks status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !json.Valid(recorder.Body.Bytes()) || !strings.Contains(recorder.Body.String(), "failure_reason") {
t.Fatalf("tasks body=%s", recorder.Body.String())
}
if len(backend.translationTaskListParams) != 1 {
t.Fatalf("translation task query params=%#v", backend.translationTaskListParams)
}
params := backend.translationTaskListParams[0]
if params.Offset == nil || *params.Offset != 10 ||
params.Limit == nil || *params.Limit != 25 ||
params.TaskID != "textunit/v-current/Scenario" ||
params.ReleaseID != "v-current" ||
params.Destination != "MediaResources/GameData/Scenario.zip" ||
params.PathPattern != "Scenario" ||
params.ArchiveEntry != "ScenarioExcelTable.json" ||
params.Status != "queued_offline" ||
params.WorkerStatus != "failed" ||
params.ParseStatus != "ok" ||
params.Format != "json" ||
params.HasReason == nil || !*params.HasReason ||
params.HasFailureReason == nil || *params.HasFailureReason {
t.Fatalf("params=%#v", params)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/handoff", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("handoff status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "provider_runs") {
t.Fatalf("handoff body=%s", recorder.Body.String())
}
if len(backend.calls) < 2 ||
backend.calls[len(backend.calls)-2] != "translation.tasks" ||
backend.calls[len(backend.calls)-1] != "translation.handoff" {
t.Fatalf("calls=%v", backend.calls)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/memory/summary?translation_memory_path=%2Fvar%2Flib%2Fbat%2Ftranslation-memory.sqlite", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("TM summary status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"trusted_count":1`) ||
len(backend.translationMemorySummaryParams) != 1 ||
backend.translationMemorySummaryParams[0].TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" {
t.Fatalf("TM summary body=%s params=%#v", recorder.Body.String(), backend.translationMemorySummaryParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/memory/query?source_text=Hello&source_context=%7B%22destination%22%3A%22Bundle%2Fdialogue.bundle%22%7D&limit=25", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("TM query status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.translationMemoryQueryParams) != 1 ||
backend.translationMemoryQueryParams[0].SourceText != "Hello" ||
backend.translationMemoryQueryParams[0].SourceContext["destination"] != "Bundle/dialogue.bundle" ||
backend.translationMemoryQueryParams[0].Limit == nil ||
*backend.translationMemoryQueryParams[0].Limit != 25 {
t.Fatalf("TM query body=%s params=%#v", recorder.Body.String(), backend.translationMemoryQueryParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/memory/query", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("missing TM query source status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/summary?glossary_path=%2Fvar%2Flib%2Fbat%2Fglossary.sqlite", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
!strings.Contains(recorder.Body.String(), `"approved_count":1`) ||
len(backend.glossarySummaryParams) != 1 ||
backend.glossarySummaryParams[0].GlossaryPath != "/var/lib/bat/glossary.sqlite" {
t.Fatalf("Glossary summary status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossarySummaryParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/query?source_text=Sensei&limit=20", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
len(backend.glossaryQueryParams) != 1 ||
backend.glossaryQueryParams[0].SourceText != "Sensei" ||
backend.glossaryQueryParams[0].Limit == nil ||
*backend.glossaryQueryParams[0].Limit != 20 {
t.Fatalf("Glossary query status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryQueryParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose?source_text=Sensei&context=%7B%22destination%22%3A%22Bundle%2Fdialogue.bundle%22%7D", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
len(backend.glossaryDiagnoseParams) != 1 ||
backend.glossaryDiagnoseParams[0].SourceText != "Sensei" ||
backend.glossaryDiagnoseParams[0].Context["destination"] != "Bundle/dialogue.bundle" {
t.Fatalf("Glossary diagnose status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryDiagnoseParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("missing Glossary diagnose source status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks?limit=0", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid task query status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks", nil)
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated tasks status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
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 || fb.releaseStatusCalls != 1 {
t.Fatalf("status=%d doctor=%d release_status=%d", fb.statusCalls, fb.doctorCalls, fb.releaseStatusCalls)
}
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 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")
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, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
return nil, errors.New("unexpected manifest call")
}
func (p *pollingBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) {
return nil, errors.New("unexpected attestation 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")
}
if !strings.Contains(rr.Body.String(), "/admin/dashboard/") ||
!strings.Contains(rr.Body.String(), "/admin/tasks") ||
!strings.Contains(rr.Body.String(), "/admin/parse/text-units") ||
!strings.Contains(rr.Body.String(), "translation_results") ||
!strings.Contains(rr.Body.String(), "glossary_override") ||
!strings.Contains(rr.Body.String(), "task-cancel") ||
!strings.Contains(rr.Body.String(), "/admin/translation/memory/query") ||
!strings.Contains(rr.Body.String(), "translation-memory-confirm") {
t.Fatalf("openapi missing dashboard/task admin routes")
}
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 != "available" {
t.Fatalf("admin=%+v", admin)
}
if len(admin.Controls) == 0 || admin.Controls[0] != "/admin/control/reload" {
t.Fatalf("admin controls=%v", admin.Controls)
}
links := strings.Join(admin.Links, "\n")
if !strings.Contains(links, "/admin/translation/tasks") ||
!strings.Contains(links, "/admin/translation/handoff") ||
!strings.Contains(links, "/admin/translation/memory/summary") ||
!strings.Contains(links, "/admin/translation/memory/query") ||
!strings.Contains(links, "/admin/dashboard/") ||
!strings.Contains(links, "/admin/parse/text-units") ||
!strings.Contains(links, "/admin/tasks/logs") {
t.Fatalf("admin links=%v", admin.Links)
}
}
func TestAdminDashboardServesStaticAssetsWithoutAdminToken(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
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, "/admin/dashboard/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("dashboard status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("dashboard Cache-Control=%q", rr.Header().Get("Cache-Control"))
}
if !strings.Contains(rr.Header().Get("Content-Security-Policy"), "connect-src") {
t.Fatalf("dashboard CSP=%q", rr.Header().Get("Content-Security-Policy"))
}
if !strings.Contains(rr.Body.String(), "bat-api Dashboard") {
t.Fatalf("dashboard body missing title")
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/dashboard/app.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("app.js status=%d body=%s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "translation-worker-run") {
t.Fatalf("app.js missing dashboard control wiring")
}
}
func TestAdminTaskAndDiagnosticsEndpointsProxyAuthenticatedRequests(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
message := "task running"
backend := &controlBackend{fakeBackend: &fakeBackend{
doctor: &backendrpc.DoctorReport{
Command: "doctor",
Status: "ok",
Message: "healthy",
Healthy: true,
Checks: []backendrpc.DoctorCheck{{Name: "socket", OK: true, Message: "ok"}},
},
taskList: &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{{
ID: "task-sync-1",
Kind: "resource.sync",
Status: "running",
Message: &message,
}}},
}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
method string
path string
wantCall string
}{
{name: "diagnostics", method: http.MethodGet, path: "/admin/diagnostics", wantCall: "daemon.doctor"},
{name: "daemon logs", method: http.MethodGet, path: "/admin/logs?tail=20", wantCall: "daemon.logs"},
{name: "task list", method: http.MethodGet, path: "/admin/tasks", wantCall: "task.list"},
{name: "task status", method: http.MethodGet, path: "/admin/tasks/status?task_id=task-sync-1", wantCall: "task.status"},
{name: "task logs", method: http.MethodGet, path: "/admin/tasks/logs?task_id=task-sync-1", wantCall: "task.logs"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(tc.method, tc.path, nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.wantCall {
t.Fatalf("calls=%v", backend.calls)
}
})
}
request := httptest.NewRequest(http.MethodGet, "/admin/logs?tail=0", nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid logs status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/tasks/status", nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid task status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/tasks", nil)
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated tasks status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminParseEndpointsProxyAuthenticatedRequests(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "parse-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
path string
wantCall string
}{
{name: "parse status", path: "/admin/parse/status", wantCall: "parse.status"},
{name: "parse text units", path: "/admin/parse/text-units?offset=1&limit=25&destination=Bundle%2Fa.bundle&path_id=42&class_id=114&field_path=Scenario.Message&format=plain", wantCall: "parse.text_units"},
{name: "parse errors", path: "/admin/parse/errors?destination=Bundle%2Fa.bundle&limit=10", wantCall: "parse.errors"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, tc.path, nil)
request.Header.Set("Authorization", "Bearer parse-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.wantCall {
t.Fatalf("calls=%v", backend.calls)
}
})
}
if len(backend.parseTextUnitQueries) != 1 {
t.Fatalf("parse text unit queries=%#v", backend.parseTextUnitQueries)
}
query := backend.parseTextUnitQueries[0]
if query.Offset != 1 ||
query.Limit != 25 ||
query.Destination != "Bundle/a.bundle" ||
query.PathID == nil || *query.PathID != 42 ||
query.ClassID == nil || *query.ClassID != 114 ||
query.FieldPath != "Scenario.Message" ||
query.Format != "plain" {
t.Fatalf("query=%#v", query)
}
if len(backend.parseErrorQueries) != 1 || backend.parseErrorQueries[0].Destination != "Bundle/a.bundle" {
t.Fatalf("parse error queries=%#v", backend.parseErrorQueries)
}
request := httptest.NewRequest(http.MethodGet, "/admin/parse/text-units?limit=0", nil)
request.Header.Set("Authorization", "Bearer parse-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid parse query status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/parse/status", nil)
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated parse status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
action string
body string
rpcMethod string
call string
}{
{name: "reload", action: "reload", rpcMethod: "daemon.reload", call: "daemon.reload"},
{name: "restart", action: "restart", rpcMethod: "daemon.restart", call: "daemon.restart"},
{name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"},
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
{name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文","glossary_override":{"qa_identity":"gqa-v1-test","reviewer":"reviewer","reason":"approved deviation","provenance":"manual-review","confirmed_unix_seconds":100}}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
{name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"},
{name: "translation glossary add", action: "translation-glossary-add", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.add", call: "translation.glossary.add"},
{name: "translation glossary update", action: "translation-glossary-update", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","reviewer":"reviewer","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.update", call: "translation.glossary.update"},
{name: "translation glossary approve", action: "translation-glossary-approve", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"approved"}`, rpcMethod: "translation.glossary.approve", call: "translation.glossary.approve"},
{name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"},
{name: "translation glossary delete", action: "translation-glossary-delete", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"duplicate"}`, rpcMethod: "translation.glossary.delete", call: "translation.glossary.delete"},
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
{name: "localized patch manifest publish", action: "localized-publish", body: `{"patch_manifest":"/tmp/patch-manifest.json","localized_release_id":"localized-1"}`, rpcMethod: "localized.publish", call: "localized.publish"},
{name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/admin/control/"+tc.action, strings.NewReader(tc.body))
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var response AdminControlResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Action != tc.action || response.RPCMethod != tc.rpcMethod || response.Status != "accepted" {
t.Fatalf("response=%+v", response)
}
if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.call {
t.Fatalf("calls=%v", backend.calls)
}
})
}
if len(backend.translationTaskUpdates) != 1 ||
backend.translationTaskUpdates[0].Provider != "manual" ||
len(backend.translationTaskUpdates[0].TranslationResults) != 1 ||
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride == nil ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.Reviewer != "reviewer" ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.QAIdentity != "gqa-v1-test" {
t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates)
}
if len(backend.translationMemoryConfirmParams) != 1 ||
backend.translationMemoryConfirmParams[0].RecordID != "tm-record-1" ||
backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" {
t.Fatalf("TM confirm params=%#v", backend.translationMemoryConfirmParams)
}
if len(backend.glossaryMutationParams) != 2 ||
backend.glossaryMutationParams[0].TermID != "term-sensei" ||
backend.glossaryMutationParams[1].Reviewer != "reviewer" ||
len(backend.glossaryReviewParams) != 2 ||
backend.glossaryReviewParams[0].TermID != "term-sensei" ||
len(backend.glossaryDeleteParams) != 1 ||
backend.glossaryDeleteParams[0].Reason != "duplicate" {
t.Fatalf("Glossary params mutation=%#v review=%#v delete=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams, backend.glossaryDeleteParams)
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid translation update status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":"textunit/v-current/Scenario","status":"failed","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid translation result status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-worker-run", strings.NewReader(`{"concurrency":0}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid translation worker status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-memory-confirm", strings.NewReader(`{"record_id":""}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid TM confirm status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"translation_file":"/tmp/workbench.json"}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"patch_manifest":"/tmp/patch-manifest.json"}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid localized patch manifest status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
request := httptest.NewRequest(http.MethodGet, "/admin/translation/status", nil)
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/status", nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"localized.published"`) {
t.Fatalf("status body=%s", recorder.Body.String())
}
if len(backend.calls) != 1 || backend.calls[0] != "localized.status" {
t.Fatalf("calls=%v", backend.calls)
}
}
func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
action string
token string
body string
wantStatus int
wantCode string
}{
{name: "missing token", action: "repair", wantStatus: http.StatusUnauthorized, wantCode: "unauthorized"},
{name: "dangerous stop", action: "stop", token: "control-token", wantStatus: http.StatusForbidden, wantCode: "control_not_allowed"},
{name: "unknown action", action: "arbitrary-rpc", token: "control-token", wantStatus: http.StatusNotFound, wantCode: "control_not_found"},
{name: "invalid parameters", action: "repair", token: "control-token", body: `{"force":true}`, wantStatus: http.StatusBadRequest, wantCode: "invalid_control_params"},
{name: "restart invalid parameters", action: "restart", token: "control-token", body: `{"force":true}`, wantStatus: http.StatusBadRequest, wantCode: "invalid_control_params"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/admin/control/"+tc.action, strings.NewReader(tc.body))
if tc.token != "" {
request.Header.Set("Authorization", "Bearer "+tc.token)
}
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != tc.wantStatus {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var response ErrorResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Error.Code != tc.wantCode {
t.Fatalf("error=%+v", response.Error)
}
})
}
if len(backend.calls) != 0 {
t.Fatalf("rejected actions reached backend: %v", backend.calls)
}
}