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 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 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 status *backendrpc.DaemonStatusReport doctor *backendrpc.DoctorReport catalog json.RawMessage resource *backendrpc.ResourceState manifest *backendrpc.ResourceManifestPage 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++ if f.statusCalls == 0 { // status must be called first in real DiscoverAndIndex; this is asserted by call order. } return f.doctor, nil } func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) { if f.resource != nil { return f.resource, nil } return &backendrpc.ResourceState{}, nil } func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) { return f.catalog, nil } func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { return f.manifest, 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 { t.Fatalf("status=%d doctor=%d", fb.statusCalls, fb.doctorCalls) } 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, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { return nil, errors.New("unexpected manifest 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":{"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 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" { 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()) } } 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) } }