@@ -1,15 +1,28 @@
|
||||
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"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/auth"
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
@@ -25,7 +38,7 @@ func TestCompatibilityRoutes(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
for _, path := range []string{"/api/client/bootstrap", "/update-info.json", "/media-types.json", "/modules.json"} {
|
||||
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)
|
||||
@@ -39,6 +52,63 @@ func TestCompatibilityRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -66,6 +136,193 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -91,6 +348,23 @@ func TestBuiltFrontendAssetsAreServed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(value string, needles []string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, needle) {
|
||||
@@ -100,6 +374,15 @@ func containsAny(value string, needles []string) bool {
|
||||
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()
|
||||
@@ -136,6 +419,129 @@ func TestAdminLegacyRequiresAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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()
|
||||
@@ -181,6 +587,9 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
"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",
|
||||
@@ -190,15 +599,20 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
})
|
||||
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{
|
||||
Listen: ":0",
|
||||
BaseURL: "https://update.ymhut.cn",
|
||||
StorageDir: filepath.Join(root, "storage"),
|
||||
UpdatePublicDir: public,
|
||||
UpdateNoticeDir: noticeDir,
|
||||
DownloadsDir: filepath.Join(public, "downloads"),
|
||||
AdminWebDir: adminDist,
|
||||
PortalWebDir: portalDist,
|
||||
SourceCheckSeconds: 3600,
|
||||
Listen: ":0",
|
||||
BaseURL: "https://update.ymhut.cn",
|
||||
StorageDir: filepath.Join(root, "storage"),
|
||||
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,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||
@@ -206,6 +620,7 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
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 {
|
||||
@@ -235,6 +650,50 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user