package web import ( "archive/zip" "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "image/color" "image/png" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "sort" "strconv" "strings" "sync" "testing" "time" "ymhut-box/server/unified-management/internal/auth" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" "ymhut-box/server/unified-management/internal/feedback" "ymhut-box/server/unified-management/internal/legacy" "ymhut-box/server/unified-management/internal/notices" "ymhut-box/server/unified-management/internal/releases" "ymhut-box/server/unified-management/internal/sources" ) func TestCompatibilityRoutes(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() for _, path := range []string{"/api/client/bootstrap", "/update-info.json", "/update-info", "/tool-status.json", "/tool-status", "/media-types.json", "/media-types", "/modules.json", "/modules", "/api/modules"} { req := httptest.NewRequest(http.MethodGet, path, nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String()) } var payload map[string]any if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { t.Fatalf("%s did not return JSON: %v", path, err) } } } func TestClientBootstrapSupportsConditionalCaching(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() firstRequest := httptest.NewRequest(http.MethodGet, "/api/client/bootstrap", nil) firstResponse := httptest.NewRecorder() handler.ServeHTTP(firstResponse, firstRequest) if firstResponse.Code != http.StatusOK { t.Fatalf("bootstrap returned %d: %s", firstResponse.Code, firstResponse.Body.String()) } etag := firstResponse.Header().Get("ETag") if etag == "" { t.Fatal("bootstrap response is missing ETag") } if cacheControl := firstResponse.Header().Get("Cache-Control"); !strings.Contains(cacheControl, "stale-while-revalidate=300") { t.Fatalf("unexpected cache control: %q", cacheControl) } var payload map[string]any if err := json.Unmarshal(firstResponse.Body.Bytes(), &payload); err != nil { t.Fatal(err) } if payload["schemaVersion"] != float64(2) || payload["generatedAt"] == nil { t.Fatalf("bootstrap is missing snapshot metadata: %#v", payload) } conditionalRequest := httptest.NewRequest(http.MethodGet, "/api/client/bootstrap", nil) conditionalRequest.Header.Set("If-None-Match", etag) conditionalResponse := httptest.NewRecorder() handler.ServeHTTP(conditionalResponse, conditionalRequest) if conditionalResponse.Code != http.StatusNotModified { t.Fatalf("conditional bootstrap returned %d, want 304: %s", conditionalResponse.Code, conditionalResponse.Body.String()) } if conditionalResponse.Body.Len() != 0 { t.Fatalf("304 response must not include a body: %q", conditionalResponse.Body.String()) } } func TestClientBootstrapConcurrentCachePerformance(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() warmRequest := httptest.NewRequest(http.MethodGet, "/api/client/bootstrap", nil) warmResponse := httptest.NewRecorder() handler.ServeHTTP(warmResponse, warmRequest) if warmResponse.Code != http.StatusOK { t.Fatalf("warm bootstrap returned %d: %s", warmResponse.Code, warmResponse.Body.String()) } etag := warmResponse.Header().Get("ETag") if etag == "" { t.Fatal("warm bootstrap response is missing ETag") } assertConcurrentP95(t, 50, 150*time.Millisecond, http.StatusOK, func() *http.Request { return httptest.NewRequest(http.MethodGet, "/api/client/bootstrap", nil) }, handler) assertConcurrentP95(t, 50, 50*time.Millisecond, http.StatusNotModified, func() *http.Request { req := httptest.NewRequest(http.MethodGet, "/api/client/bootstrap", nil) req.Header.Set("If-None-Match", etag) return req }, handler) } func assertConcurrentP95(t *testing.T, count int, limit time.Duration, expectedStatus int, request func() *http.Request, handler http.Handler) { t.Helper() start := make(chan struct{}) durations := make([]time.Duration, count) statuses := make([]int, count) var wg sync.WaitGroup wg.Add(count) for i := range count { go func(index int) { defer wg.Done() <-start began := time.Now() response := httptest.NewRecorder() handler.ServeHTTP(response, request()) durations[index] = time.Since(began) statuses[index] = response.Code }(i) } close(start) wg.Wait() for i, status := range statuses { if status != expectedStatus { t.Fatalf("request %d returned %d, want %d", i, status, expectedStatus) } } sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) p95 := durations[(count*95+99)/100-1] if p95 > limit { t.Fatalf("%d concurrent requests p95 was %s, want <= %s", count, p95, limit) } t.Logf("%d concurrent requests p95=%s (limit %s)", count, p95, limit) } func TestLegacyPublicContractFields(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() cases := []struct { Path string RequiredKeys []string }{ {Path: "/update-info.json", RequiredKeys: []string{"app_version", "manifest_version", "packages", "modules"}}, {Path: "/update-info", RequiredKeys: []string{"app_version", "manifest_version", "packages", "modules"}}, {Path: "/tool-status.json", RequiredKeys: []string{"ok"}}, {Path: "/tool-status", RequiredKeys: []string{"ok"}}, {Path: "/modules.json", RequiredKeys: []string{"modules"}}, {Path: "/modules", RequiredKeys: []string{"modules"}}, {Path: "/api/modules", RequiredKeys: []string{"modules"}}, {Path: "/media-types.json", RequiredKeys: []string{"layout_version", "last_updated", "categories"}}, {Path: "/media-types", RequiredKeys: []string{"layout_version", "last_updated", "categories"}}, } for _, tc := range cases { req := httptest.NewRequest(http.MethodGet, tc.Path, nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("%s returned %d: %s", tc.Path, res.Code, res.Body.String()) } var payload map[string]any if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { t.Fatalf("%s did not return JSON: %v", tc.Path, err) } assertJSONKeys(t, tc.Path, payload, tc.RequiredKeys) } req := httptest.NewRequest(http.MethodGet, "/downloads/fixture.txt", nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("download returned %d: %s", res.Code, res.Body.String()) } if strings.TrimSpace(res.Body.String()) != "download fixture" { t.Fatalf("unexpected download body: %q", res.Body.String()) } } func TestDownloadRejectsPathEscape(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() for _, path := range []string{"/downloads/../update-info.json", "/downloads/%2e%2e/update-info.json", "/downloads/foo\\bar.exe"} { req := httptest.NewRequest(http.MethodGet, path, nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusForbidden && res.Code != http.StatusNotFound { t.Fatalf("%s returned %d, want forbidden or not found: %s", path, res.Code, res.Body.String()) } } } func TestClientBootstrapAndEndpointsShape(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() for _, path := range []string{"/api/client/bootstrap", "/api/client/endpoints", "/api/client/sources", "/api/client/notices"} { req := httptest.NewRequest(http.MethodGet, path, nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String()) } var payload map[string]any if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { t.Fatal(err) } if path == "/api/client/sources" { if payload["categories"] == nil { t.Fatalf("%s missing categories: %#v", path, payload) } continue } if path == "/api/client/bootstrap" { branding, ok := payload["branding"].(map[string]any) if !ok { t.Fatalf("bootstrap missing branding: %#v", payload) } if branding["developerName"] != "YMhut" || branding["feedbackEmail"] != "support@ymhut.cn" { t.Fatalf("unexpected branding defaults: %#v", branding) } if branding["siteName"] != "YMhut Box" || branding["portalTitle"] != "YMhut Box 统一服务门户" || branding["adminTitle"] != "YMhut 统一管理台" { t.Fatalf("unexpected extended branding defaults: %#v", branding) } } if payload["ok"] != true { t.Fatalf("%s missing ok=true: %#v", path, payload) } } } func TestAdminDeleteSourcePublishesCompatibilityJSON(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() session, csrf, err := loginForTest(handler) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodDelete, "/api/admin/sources/demo", nil) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) req.Header.Set("X-CSRF-Token", csrf) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("delete source returned %d: %s", res.Code, res.Body.String()) } mediaReq := httptest.NewRequest(http.MethodGet, "/media-types.json", nil) mediaRes := httptest.NewRecorder() handler.ServeHTTP(mediaRes, mediaReq) if mediaRes.Code != http.StatusOK { t.Fatalf("media-types returned %d: %s", mediaRes.Code, mediaRes.Body.String()) } if strings.Contains(mediaRes.Body.String(), `"demo"`) { t.Fatalf("deleted source leaked into media-types.json: %s", mediaRes.Body.String()) } updateReq := httptest.NewRequest(http.MethodGet, "/update-info.json", nil) updateRes := httptest.NewRecorder() handler.ServeHTTP(updateRes, updateReq) if updateRes.Code != http.StatusOK { t.Fatalf("update-info returned %d: %s", updateRes.Code, updateRes.Body.String()) } var updatePayload map[string]any if err := json.Unmarshal(updateRes.Body.Bytes(), &updatePayload); err != nil { t.Fatal(err) } assertJSONKeys(t, "update-info after source delete", updatePayload, []string{"app_version", "manifest_version", "packages", "modules"}) } func TestAdminAuditPaginationAndBranding(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() session, csrf, err := loginForTest(handler) if err != nil { t.Fatal(err) } for i := 0; i < 40; i++ { body := strings.NewReader(`{"siteName":"YMhut Box Pro","portalTitle":"前台服务门户","portalSubtitle":"公开发布与接口状态","adminTitle":"后台统一服务管理","adminSubtitle":"发布、反馈与运维","siteIconUrl":"/assets/favicon.ico","logoUrl":"/assets/favicon.ico","developerName":"YMhut","feedbackEmail":"support@ymhut.cn"}`) req := httptest.NewRequest(http.MethodPost, "/api/admin/system/branding", body) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) req.Header.Set("X-CSRF-Token", csrf) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("branding save %d returned %d: %s", i, res.Code, res.Body.String()) } if i == 0 { var saved struct { Branding map[string]any `json:"branding"` } if err := json.Unmarshal(res.Body.Bytes(), &saved); err != nil { t.Fatal(err) } if saved.Branding["siteName"] != "YMhut Box Pro" || saved.Branding["portalTitle"] != "前台服务门户" || saved.Branding["adminTitle"] != "后台统一服务管理" || saved.Branding["logoUrl"] != "/assets/favicon.ico" { t.Fatalf("extended branding fields not saved: %#v", saved.Branding) } } } req := httptest.NewRequest(http.MethodGet, "/api/admin/system/audit?page=1&perPage=35&type=system.branding.saved", nil) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("audit returned %d: %s", res.Code, res.Body.String()) } var payload struct { Items []any `json:"items"` Page struct { Total int `json:"total"` Page int `json:"page"` PerPage int `json:"perPage"` } `json:"page"` } if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { t.Fatal(err) } if payload.Page.Page != 1 || payload.Page.PerPage != 35 { t.Fatalf("unexpected audit page metadata: %#v", payload.Page) } if payload.Page.Total < 40 { t.Fatalf("expected at least 40 branding audit records, got %d", payload.Page.Total) } if len(payload.Items) > 35 { t.Fatalf("expected at most 35 audit items, got %d", len(payload.Items)) } } func TestLegacyFeedbackStatusDTOContract(t *testing.T) { payload := legacyFeedbackStatus(db.Feedback{ Code: "FB-20260626-ABCDEF", Status: "processing", StatusDetail: "公开进度", Category: "issue", Priority: "normal", PublicReply: "公开回复", Note: "内部备注", Assignee: "owner", HandledBy: "admin", Attachment: "private.zip", PackagePath: "storage/feedback/private.zip", EncryptedPackagePath: "storage/feedback/private.ymfb", MailSent: true, CreatedAt: "2026-06-26T00:00:00Z", UpdatedAt: "2026-06-26T00:10:00Z", LastActivityAt: "2026-06-26T00:20:00Z", }, true) data, err := json.Marshal(payload) if err != nil { t.Fatal(err) } var out map[string]any if err := json.Unmarshal(data, &out); err != nil { t.Fatal(err) } assertJSONKeys(t, "legacy feedback status", out, []string{"ok", "code", "status", "statusLabel", "statusDetail", "category", "priority", "hasReply", "reply", "receivedAt", "updatedAt", "mailSent", "duplicate"}) for _, privateKey := range []string{"note", "assignee", "handledBy", "attachments", "events", "legacyEvents", "mailRecords", "path", "attachment", "packagePath", "encryptedPackagePath", "packageSha256", "plainPackageSha256"} { if _, ok := out[privateKey]; ok { t.Fatalf("legacy DTO leaked private key %q: %#v", privateKey, out) } } } func TestLegacyFeedbackPublicStatusShape(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"title":"旧版反馈","type":"issue","severity":"normal","body":"客户端反馈内容"}`)) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("submit returned %d: %s", res.Code, res.Body.String()) } var submitted map[string]any if err := json.Unmarshal(res.Body.Bytes(), &submitted); err != nil { t.Fatal(err) } code, _ := submitted["code"].(string) if code == "" || submitted["statusLabel"] == nil || submitted["feedback"] != nil { t.Fatalf("unexpected submit payload: %#v", submitted) } req = httptest.NewRequest(http.MethodGet, "/?api=status&code="+code, nil) res = httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("status returned %d: %s", res.Code, res.Body.String()) } var status map[string]any if err := json.Unmarshal(res.Body.Bytes(), &status); err != nil { t.Fatal(err) } if status["code"] != code || status["statusLabel"] == nil || status["feedback"] != nil { t.Fatalf("unexpected status payload: %#v", status) } for _, privateKey := range []string{"note", "assignee", "handledBy", "attachment", "attachments", "path", "packagePath", "encryptedPackagePath", "events", "legacyEvents", "mailRecords", "packageSha256", "plainPackageSha256"} { if _, ok := status[privateKey]; ok { t.Fatalf("status leaked private key %q: %#v", privateKey, status) } } } func TestLegacyFeedbackMultipartFallback(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() body := &bytes.Buffer{} writer := multipart.NewWriter(body) _ = writer.WriteField("subject", "Multipart legacy feedback") _ = writer.WriteField("category", "issue") _ = writer.WriteField("priority", "normal") _ = writer.WriteField("email", "user@example.com") _ = writer.WriteField("message", "Submitted by an old multipart client.") if part, err := writer.CreateFormFile("ignored", "note.txt"); err == nil { _, _ = io.WriteString(part, "not signed, should fall back") } _ = writer.Close() req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set("Content-Type", writer.FormDataContentType()) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("multipart submit returned %d: %s", res.Code, res.Body.String()) } var payload map[string]any if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { t.Fatal(err) } if payload["code"] == "" || payload["feedback"] != nil { t.Fatalf("unexpected multipart payload: %#v", payload) } } func TestLegacyFeedbackSignedEncryptedMultipartRoute(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() plain := routeZipBytes(t, map[string]string{ "feedback.json": `{"request":{"title":"Signed route feedback","type":"issue","severity":"major","contact":"user@example.com","body":"Signed package body."}}`, "summary.txt": "signed route summary", }) encrypted := routeEncryptPackage(t, plain, "ymhut-box-feedback-package-v1") encryptedHash := routeSHA256Hex(encrypted) plainHash := routeSHA256Hex(plain) payloadData, err := json.Marshal(map[string]any{ "feedbackCode": "FB-20260626-ABC123", "title": "Signed route feedback", "type": "issue", "severity": "major", "contact": "user@example.com", "bodyLength": 20, "packageEncrypted": true, "encryption": feedback.PackageMagic, "packageBytes": len(encrypted), "packageSha256": encryptedHash, "plainPackageBytes": len(plain), "plainPackageSha256": plainHash, "createdAt": "2026-06-26T00:00:00Z", }) if err != nil { t.Fatal(err) } payload := string(payloadData) timestamp := strconv.FormatInt(time.Now().Unix(), 10) body := &bytes.Buffer{} writer := multipart.NewWriter(body) _ = writer.WriteField("payload", payload) _ = writer.WriteField("timestamp", timestamp) _ = writer.WriteField("nonce", "route-test") _ = writer.WriteField("packageSha256", encryptedHash) _ = writer.WriteField("signature", feedback.SignWithKey("ymhut-box-feedback-client-v1", timestamp, "route-test", encryptedHash, payload)) part, err := writer.CreateFormFile("package", "feedback.ymfb") if err != nil { t.Fatal(err) } _, _ = io.Copy(part, bytes.NewReader(encrypted)) _ = writer.Close() req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set("Content-Type", writer.FormDataContentType()) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("signed multipart submit returned %d: %s", res.Code, res.Body.String()) } var submitted map[string]any if err := json.Unmarshal(res.Body.Bytes(), &submitted); err != nil { t.Fatal(err) } if submitted["code"] != "FB-20260626-ABC123" || submitted["duplicate"] != nil { t.Fatalf("unexpected signed submit payload: %#v", submitted) } req = httptest.NewRequest(http.MethodGet, "/?api=status&code=FB-20260626-ABC123", nil) res = httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("signed status returned %d: %s", res.Code, res.Body.String()) } var status map[string]any if err := json.Unmarshal(res.Body.Bytes(), &status); err != nil { t.Fatal(err) } if status["code"] != "FB-20260626-ABC123" || status["statusLabel"] == nil || status["reply"] == nil { t.Fatalf("unexpected signed status payload: %#v", status) } for _, privateKey := range []string{"note", "assignee", "handledBy", "attachments", "events", "mailRecords", "packagePath", "encryptedPackagePath", "path"} { if _, ok := status[privateKey]; ok { t.Fatalf("signed status leaked private key %q: %#v", privateKey, status) } } } func TestBuiltFrontendAssetsAreServed(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() for _, item := range []struct { Path string ContentTypes []string }{ {Path: "/assets/portal.css", ContentTypes: []string{"text/css"}}, {Path: "/assets/portal.js", ContentTypes: []string{"text/javascript", "application/javascript"}}, {Path: "/admin/assets/admin.css", ContentTypes: []string{"text/css"}}, {Path: "/admin/assets/admin.js", ContentTypes: []string{"text/javascript", "application/javascript"}}, } { req := httptest.NewRequest(http.MethodGet, item.Path, nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("%s returned %d: %s", item.Path, res.Code, res.Body.String()) } if got := res.Header().Get("Content-Type"); !containsAny(got, item.ContentTypes) { t.Fatalf("%s content type = %q, want one of %v", item.Path, got, item.ContentTypes) } } } func TestServerBrandAssetsAreServed(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() for _, item := range []struct { Path string ContentTypes []string }{ {Path: "/favicon.ico", ContentTypes: []string{"image/x-icon", "image/vnd.microsoft.icon"}}, {Path: "/admin/favicon.ico", ContentTypes: []string{"image/x-icon", "image/vnd.microsoft.icon"}}, {Path: "/assets/favicon.ico", ContentTypes: []string{"image/x-icon", "image/vnd.microsoft.icon"}}, {Path: "/assets/developer-avatar.png", ContentTypes: []string{"image/png"}}, } { req := httptest.NewRequest(http.MethodGet, item.Path, nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("%s returned %d: %s", item.Path, res.Code, res.Body.String()) } if got := res.Header().Get("Content-Type"); !containsAny(got, item.ContentTypes) { t.Fatalf("%s content type = %q, want one of %v", item.Path, got, item.ContentTypes) } } } func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() for _, path := range []string{"/admin/system", "/admin/database", "/admin/health", "/admin/settings", "/admin/audit"} { req := httptest.NewRequest(http.MethodGet, path, nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String()) } if !strings.Contains(res.Body.String(), "/admin/assets/admin.js") { t.Fatalf("%s did not serve admin SPA shell: %s", path, res.Body.String()) } if got := res.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") { t.Fatalf("%s cache control = %q, want no-store", path, got) } } } func TestMissingAdminAssetIsNotCached(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() req := httptest.NewRequest(http.MethodGet, "/admin/assets/missing.js", nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusNotFound { t.Fatalf("missing asset returned %d: %s", res.Code, res.Body.String()) } if got := res.Header().Get("Cache-Control"); got != "no-store" { t.Fatalf("missing asset cache control = %q, want no-store", got) } } func TestAdminReleasePackageUploadStreamsMultipart(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() session, csrf, err := loginForTest(handler) if err != nil { t.Fatal(err) } req := newReleaseUploadRequest(t, "YMhut_Box_WinUI_Setup_2.0.8_x64.exe", []byte("package bytes"), true) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) req.Header.Set("X-CSRF-Token", csrf) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("upload returned %d: %s", res.Code, res.Body.String()) } var payload map[string]any if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { t.Fatal(err) } if payload["ok"] != true { t.Fatalf("unexpected upload payload: %#v", payload) } } func TestAdminReleasePackageUploadRejectsMissingAndOversizedFile(t *testing.T) { for _, tc := range []struct { name string data []byte include bool wantStatus int wantCode string }{ {name: "missing", include: false, wantStatus: http.StatusBadRequest, wantCode: "FILE_REQUIRED"}, {name: "empty", include: true, data: []byte{}, wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_EMPTY"}, {name: "oversized", include: true, data: bytes.Repeat([]byte{'x'}, (1<<20)+1), wantStatus: http.StatusRequestEntityTooLarge, wantCode: "PACKAGE_TOO_LARGE"}, {name: "unsafe-name", include: true, data: []byte("package"), wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_UPLOAD_FAILED"}, } { t.Run(tc.name, func(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() session, csrf, err := loginForTest(handler) if err != nil { t.Fatal(err) } fileName := "package.exe" if tc.name == "unsafe-name" { fileName = "../package.exe" } req := newReleaseUploadRequest(t, fileName, tc.data, tc.include) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) req.Header.Set("X-CSRF-Token", csrf) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != tc.wantStatus || !strings.Contains(res.Body.String(), tc.wantCode) { t.Fatalf("upload returned %d %s, want %d containing %s", res.Code, res.Body.String(), tc.wantStatus, tc.wantCode) } }) } } func TestAdminReleasePackageUploadReportsInterruptedMultipart(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() session, csrf, err := loginForTest(handler) if err != nil { t.Fatal(err) } const boundary = "ymhut-interrupted-upload" body := bytes.NewBufferString("--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"package.exe\"\r\n" + "Content-Type: application/octet-stream\r\n\r\npartial package") req := httptest.NewRequest(http.MethodPost, "/api/admin/releases/packages", body) req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) req.Header.Set("X-CSRF-Token", csrf) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "UPLOAD_INTERRUPTED") { t.Fatalf("interrupted upload returned %d %s", res.Code, res.Body.String()) } } func newReleaseUploadRequest(t *testing.T, name string, data []byte, includeFile bool) *http.Request { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) if includeFile { part, err := writer.CreateFormFile("file", name) if err != nil { t.Fatal(err) } if _, err := part.Write(data); err != nil { t.Fatal(err) } } if err := writer.WriteField("version", "2.0.8"); err != nil { t.Fatal(err) } if strings.Contains(name, "..") { if err := writer.WriteField("fileName", name); err != nil { t.Fatal(err) } } if err := writer.Close(); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, "/api/admin/releases/packages", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) return req } func TestValidateAdminDiskBuildRejectsMissingReferencedAsset(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(``), 0o644); err != nil { t.Fatal(err) } if err := validateAdminDiskBuild(dir); err == nil || !strings.Contains(err.Error(), "assets/missing.js") { t.Fatalf("validateAdminDiskBuild returned %v, want missing asset error", err) } } func TestIncompleteAdminDiskBuildDoesNotServeDiskAssets(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(``), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "assets", "stale.js"), []byte(`stale disk asset`), 0o644); err != nil { t.Fatal(err) } r := &router{cfg: &config.Config{AdminWebDir: dir}} req := httptest.NewRequest(http.MethodGet, "/admin/assets/stale.js", nil) res := httptest.NewRecorder() r.serveAdminAsset(res, req, "assets/stale.js") if res.Code != http.StatusNotFound { t.Fatalf("incomplete disk asset returned %d: %s", res.Code, res.Body.String()) } if strings.Contains(res.Body.String(), "stale disk asset") { t.Fatal("incomplete disk build served a stale disk asset") } if got := res.Header().Get("Cache-Control"); got != "no-store" { t.Fatalf("incomplete asset cache control = %q, want no-store", got) } } func containsAny(value string, needles []string) bool { for _, needle := range needles { if strings.Contains(value, needle) { return true } } return false } func assertJSONKeys(t *testing.T, label string, payload map[string]any, keys []string) { t.Helper() for _, key := range keys { if _, ok := payload[key]; !ok { t.Fatalf("%s missing key %q: %#v", label, key, payload) } } } func TestReleaseNoticesRoutes(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() for _, path := range []string{"/api/client/notices", "/api/client/notices/2.0.0"} { req := httptest.NewRequest(http.MethodGet, path, nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String()) } } req := httptest.NewRequest(http.MethodGet, "/api/client/releases", nil) res := httptest.NewRecorder() handler.ServeHTTP(res, req) var payload map[string]any if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { t.Fatal(err) } if payload["notices"] == nil { t.Fatalf("release manifest missing notices: %#v", payload) } } func TestAdminLegacyRequiresAuth(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() req := httptest.NewRequest(http.MethodPut, "/api/admin/legacy/media-types", bytes.NewBufferString(`{"raw":"{}"}`)) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusUnauthorized { t.Fatalf("expected unauthorized, got %d", res.Code) } } func TestAdminLegacyUpdateInfoSyncsReleaseNotice(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() session, csrf, err := loginForTest(handler) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]string{ "raw": `{"app_version":"2.0.7.5","title":"YMhut Box 2.0.7.5","message":"随机放映室优化","release_notes":"修复图片源和全屏预览"}`, }) req := httptest.NewRequest(http.MethodPut, "/api/admin/legacy/update-info", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-CSRF-Token", csrf) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusOK { t.Fatalf("save update-info returned %d: %s", res.Code, res.Body.String()) } listReq := httptest.NewRequest(http.MethodGet, "/api/admin/releases/notices", nil) listReq.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) listRes := httptest.NewRecorder() handler.ServeHTTP(listRes, listReq) if listRes.Code != http.StatusOK { t.Fatalf("notice list returned %d: %s", listRes.Code, listRes.Body.String()) } var payload struct { Items []struct { Version string `json:"version"` Title string `json:"title"` } `json:"items"` } if err := json.Unmarshal(listRes.Body.Bytes(), &payload); err != nil { t.Fatal(err) } found := false for _, item := range payload.Items { if item.Version == "2.0.7.5" && item.Title == "YMhut Box 2.0.7.5" { found = true } } if !found { t.Fatalf("synced release notice not found: %#v", payload.Items) } } func TestAdminLegacyValidationErrorIsChinese(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() session, csrf, err := loginForTest(handler) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]string{"raw": `{"message":"missing version and title"}`}) req := httptest.NewRequest(http.MethodPost, "/api/admin/legacy/update-info/validate", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-CSRF-Token", csrf) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusBadRequest { t.Fatalf("expected validation failure, got %d: %s", res.Code, res.Body.String()) } if strings.Contains(res.Body.String(), "update-info requires app_version or title") { t.Fatalf("english validation leaked: %s", res.Body.String()) } if !strings.Contains(res.Body.String(), "更新 JSON 需要填写 app_version 或 title") { t.Fatalf("missing chinese validation message: %s", res.Body.String()) } } func TestAdminWriteRequiresCSRF(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() session, _, err := loginForTest(handler) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, "/api/admin/sources/check", bytes.NewBufferString(`{}`)) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) res := httptest.NewRecorder() handler.ServeHTTP(res, req) if res.Code != http.StatusForbidden { t.Fatalf("expected forbidden without csrf, got %d: %s", res.Code, res.Body.String()) } } func loginForTest(handler http.Handler) (string, string, error) { captchaReq := httptest.NewRequest(http.MethodGet, "/api/admin/auth/captcha", nil) captchaRes := httptest.NewRecorder() handler.ServeHTTP(captchaRes, captchaReq) if captchaRes.Code != http.StatusOK { return "", "", errors.New(captchaRes.Body.String()) } var captchaPayload struct { CaptchaID string `json:"captchaId"` Image string `json:"image"` } if err := json.Unmarshal(captchaRes.Body.Bytes(), &captchaPayload); err != nil { return "", "", err } answer, err := readTestCaptcha(captchaPayload.Image) if err != nil { return "", "", err } loginBody, _ := json.Marshal(map[string]string{ "username": "admin", "password": "admin", "captchaId": captchaPayload.CaptchaID, "captcha": answer, }) loginReq := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", bytes.NewReader(loginBody)) loginReq.Header.Set("Content-Type", "application/json") loginRes := httptest.NewRecorder() handler.ServeHTTP(loginRes, loginReq) if loginRes.Code != http.StatusOK { return "", "", errors.New(loginRes.Body.String()) } var loginPayload struct { OK bool `json:"ok"` CSRFToken string `json:"csrfToken"` Message string `json:"message"` } if err := json.Unmarshal(loginRes.Body.Bytes(), &loginPayload); err != nil { return "", "", err } if !loginPayload.OK { return "", "", errors.New(loginPayload.Message) } for _, cookie := range loginRes.Result().Cookies() { if cookie.Name == auth.SessionCookie { return cookie.Value, loginPayload.CSRFToken, nil } } return "", "", errors.New("session cookie not set") } func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() body := bytes.NewBufferString(`{"username":"admin","password":"wrong","captchaId":"missing","captcha":"00000"}`) req := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", body) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() started := time.Now() handler.ServeHTTP(res, req) if time.Since(started) > time.Second { t.Fatal("invalid login request did not return promptly") } if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "CAPTCHA_INVALID") { t.Fatalf("invalid login returned %d: %s", res.Code, res.Body.String()) } if got := res.Header().Get("Cache-Control"); got != "no-store" { t.Fatalf("login cache control = %q, want no-store", got) } } func TestRemoteHostRemovesEphemeralPort(t *testing.T) { if got := remoteHost("127.0.0.1:51842"); got != "127.0.0.1" { t.Fatalf("remoteHost returned %q", got) } if got := remoteHost("[::1]:51842"); got != "::1" { t.Fatalf("remoteHost returned %q", got) } } func readTestCaptcha(dataURL string) (string, error) { const prefix = "data:image/png;base64," raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix)) if err != nil { return "", err } img, err := png.Decode(bytes.NewReader(raw)) if err != nil { return "", err } var builder strings.Builder for index := 0; index < 5; index++ { x := 18 + index*32 y := 13 mask := [7]bool{ isCaptchaInk(img.At(x+11, y+2)), isCaptchaInk(img.At(x+20, y+12)), isCaptchaInk(img.At(x+20, y+28)), isCaptchaInk(img.At(x+11, y+34)), isCaptchaInk(img.At(x+2, y+28)), isCaptchaInk(img.At(x+2, y+12)), isCaptchaInk(img.At(x+11, y+18)), } digit := -1 for candidate, segments := range testCaptchaSegments { if segments == mask { digit = candidate break } } if digit < 0 { return "", errors.New("captcha digit could not be read") } builder.WriteByte(byte('0' + digit)) } return builder.String(), nil } func isCaptchaInk(colorValue color.Color) bool { r, g, b, _ := colorValue.RGBA() return r>>8 < 80 && g>>8 < 100 && b>>8 < 130 } var testCaptchaSegments = [10][7]bool{ {true, true, true, true, true, true, false}, {false, true, true, false, false, false, false}, {true, true, false, true, true, false, true}, {true, true, true, true, false, false, true}, {false, true, true, false, false, true, true}, {true, false, true, true, false, true, true}, {true, false, true, true, true, true, true}, {true, true, true, false, false, false, false}, {true, true, true, true, true, true, true}, {true, true, true, true, false, true, true}, } func testRouter(t *testing.T) (http.Handler, func()) { t.Helper() root := t.TempDir() public := filepath.Join(root, "public") noticeDir := filepath.Join(root, "update-notice") assetDir := filepath.Join(root, "assets") if err := os.MkdirAll(filepath.Join(public, "downloads"), 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(assetDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(assetDir, "favicon.ico"), []byte("ico"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(assetDir, "developer-avatar.png"), []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, 0o644); err != nil { t.Fatal(err) } adminDist := filepath.Join(root, "admin") portalDist := filepath.Join(root, "portal") for _, dir := range []string{filepath.Join(adminDist, "assets"), filepath.Join(portalDist, "assets")} { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(portalDist, "index.html"), []byte(``), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(portalDist, "assets", "portal.css"), []byte(`body{}`), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(portalDist, "assets", "portal.js"), []byte(`console.log("portal")`), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(adminDist, "index.html"), []byte(``), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(adminDist, "assets", "admin.css"), []byte(`body{}`), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(adminDist, "assets", "admin.js"), []byte(`console.log("admin")`), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(noticeDir, 0o755); err != nil { t.Fatal(err) } mustWriteJSON(t, filepath.Join(public, "update-info.json"), map[string]any{"app_version": "0.0.1"}) mustWriteJSON(t, filepath.Join(public, "tool-status.json"), map[string]any{"ok": true}) mustWriteJSON(t, filepath.Join(public, "modules.json"), map[string]any{"modules": []any{}}) mustWriteJSON(t, filepath.Join(public, "media-types.json"), map[string]any{ "categories": []map[string]any{{ "id": "image", "name": "image", "subcategories": []map[string]any{{"id": "demo", "name": "demo", "api_url": "https://example.com/demo"}}, }}, }) if err := os.WriteFile(filepath.Join(public, "downloads", "fixture.txt"), []byte("download fixture\n"), 0o644); err != nil { t.Fatal(err) } mustWriteJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{ "schema_version": 1, "latest_version": "2.0.0", "latest_notice_file": "2.0.0.json", "latest": map[string]any{"version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release"}, "versions": []map[string]any{{"version": "2.0.0", "notice_file": "2.0.0.json", "summary": "Initial release"}}, }) mustWriteJSON(t, filepath.Join(noticeDir, "2.0.0.json"), map[string]any{"app_version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release", "release_notes_md": "## Initial"}) cfg := &config.Config{ BaseDir: root, ConfigPath: filepath.Join(root, "config.json"), Listen: ":0", BaseURL: "https://update.ymhut.cn", StorageDir: filepath.Join(root, "storage"), DataDir: filepath.Join(root, "data"), UpdatePublicDir: public, UpdateNoticeDir: noticeDir, DownloadsDir: filepath.Join(public, "downloads"), AdminWebDir: adminDist, PortalWebDir: portalDist, SourceCheckSeconds: 3600, ClientSignatureKey: "ymhut-box-feedback-client-v1", PackageEncryptionKey: "ymhut-box-feedback-package-v1", TimestampWindowSeconds: 600, MaxRequestBytes: 12 << 20, MaxPackageBytes: 10 << 20, ReleaseUploadMaxBytes: 1 << 20, Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(root, "storage", "unified.sqlite"), FailoverEnabled: true, HotSyncEnabled: true, HealthIntervalSec: 3600, }, UploadGuard: config.UploadGuardConfig{MaxZipFiles: 80, MaxDecompressedBytes: 30 << 20, MaxSingleFileBytes: 8 << 20, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 << 10, AllowUnexpectedZipFiles: true}, } store, err := db.Open(cfg) if err != nil { t.Fatal(err) } if err := store.EnsureDefaultAdmin(context.Background()); err != nil { t.Fatal(err) } sourceService := sources.NewService(cfg, store) if err := sourceService.ImportLegacyMediaTypesIfEmpty(context.Background()); err != nil { t.Fatal(err) } noticeService := notices.NewService(cfg, store) if err := noticeService.Import(context.Background()); err != nil { t.Fatal(err) } handler := NewRouter( cfg, store, auth.NewService(store), feedback.NewService(cfg, store), releases.NewService(cfg, store, noticeService), sourceService, legacy.NewService(cfg, store), noticeService, ) return handler, func() { _ = store.Close() } } func routeZipBytes(t *testing.T, files map[string]string) []byte { t.Helper() var buf bytes.Buffer writer := zip.NewWriter(&buf) for name, body := range files { entry, err := writer.Create(name) if err != nil { t.Fatal(err) } _, _ = entry.Write([]byte(body)) } if err := writer.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func routeEncryptPackage(t *testing.T, plain []byte, keyMaterial string) []byte { t.Helper() key := sha256.Sum256([]byte(keyMaterial)) block, err := aes.NewCipher(key[:]) if err != nil { t.Fatal(err) } gcm, err := cipher.NewGCM(block) if err != nil { t.Fatal(err) } nonce := []byte("123456789012") sealed := gcm.Seal(nil, nonce, plain, []byte(feedback.PackageMagic)) ciphertext := sealed[:len(sealed)-gcm.Overhead()] tag := sealed[len(sealed)-gcm.Overhead():] out := []byte(feedback.PackageMagic) out = append(out, nonce...) out = append(out, tag...) out = append(out, ciphertext...) return out } func routeSHA256Hex(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } func mustWriteJSON(t *testing.T, path string, payload any) { t.Helper() data, err := json.Marshal(payload) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatal(err) } }