1488 lines
54 KiB
Go
1488 lines
54 KiB
Go
package web
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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/adminassets"
|
|
"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 TestRequestBaseURLUsesTrustedForwardedValues(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "http://internal:33550/api/client/bootstrap", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "HTTPS, http")
|
|
req.Header.Set("X-Forwarded-Host", "updates.example.com:8443, internal:33550")
|
|
|
|
if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://updates.example.com:8443" {
|
|
t.Fatalf("requestBaseURL() = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRequestBaseURLRejectsInvalidForwardedValues(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "https://internal:33550/api/client/bootstrap", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "file")
|
|
req.Header.Set("X-Forwarded-Host", "user@evil.example")
|
|
if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://internal:33550" {
|
|
t.Fatalf("requestBaseURL() = %q", got)
|
|
}
|
|
|
|
req.Host = "bad host"
|
|
req.Header.Set("X-Forwarded-Host", "updates.example.com:99999")
|
|
if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://fallback.example.com" {
|
|
t.Fatalf("requestBaseURL() with invalid hosts = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPublicSnapshotCacheBoundsHostVariants(t *testing.T) {
|
|
service := newPublicSnapshotService(time.Hour)
|
|
for index := 0; index < maxPublicSnapshotEntries+20; index++ {
|
|
key := fmt.Sprintf("bootstrap|https://host-%d.example.com", index)
|
|
service.Get(key, func(time.Time) any { return map[string]any{"ok": true} })
|
|
}
|
|
if got := len(service.entries); got != maxPublicSnapshotEntries {
|
|
t.Fatalf("snapshot cache has %d entries, want %d", got, maxPublicSnapshotEntries)
|
|
}
|
|
}
|
|
|
|
func TestPublicSnapshotCacheRemovesExpiredEntries(t *testing.T) {
|
|
service := newPublicSnapshotService(time.Millisecond)
|
|
service.Get("expired", func(time.Time) any { return map[string]any{"value": 1} })
|
|
time.Sleep(2 * time.Millisecond)
|
|
service.Get("current", func(time.Time) any { return map[string]any{"value": 2} })
|
|
|
|
if _, exists := service.entries["expired"]; exists {
|
|
t.Fatal("expired snapshot was not removed")
|
|
}
|
|
}
|
|
|
|
func TestClientAddressTrustsForwardedIPOnlyFromLoopbackProxy(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "http://internal/", nil)
|
|
req.RemoteAddr = "127.0.0.1:44000"
|
|
req.Header.Set("X-Real-IP", "203.0.113.8")
|
|
req.Header.Set("X-Forwarded-For", "198.51.100.2, 203.0.113.9")
|
|
if got := clientAddress(req); got != "203.0.113.8" {
|
|
t.Fatalf("clientAddress() = %q", got)
|
|
}
|
|
|
|
req.Header.Set("X-Real-IP", "not-an-ip")
|
|
if got := clientAddress(req); got != "203.0.113.9" {
|
|
t.Fatalf("clientAddress() XFF fallback = %q", got)
|
|
}
|
|
|
|
req.RemoteAddr = "192.0.2.25:44000"
|
|
req.Header.Set("X-Real-IP", "203.0.113.10")
|
|
if got := clientAddress(req); got != "192.0.2.25" {
|
|
t.Fatalf("clientAddress() trusted a non-loopback peer: %q", got)
|
|
}
|
|
}
|
|
|
|
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 TestPublicAuthBootstrapDoesNotExposeAdministratorCredentials(t *testing.T) {
|
|
handler, cleanup := testRouter(t)
|
|
defer cleanup()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/auth/bootstrap", nil)
|
|
res := httptest.NewRecorder()
|
|
handler.ServeHTTP(res, req)
|
|
if res.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap 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)
|
|
}
|
|
for _, field := range []string{"defaultUsername", "defaultPassword", "isDefaultPassword"} {
|
|
if _, exists := payload[field]; exists {
|
|
t.Fatalf("public bootstrap exposed %s: %#v", field, payload)
|
|
}
|
|
}
|
|
if cacheControl := res.Header().Get("Cache-Control"); cacheControl != "no-store" {
|
|
t.Fatalf("bootstrap cache control = %q, want no-store", cacheControl)
|
|
}
|
|
}
|
|
|
|
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 TestClientReferenceDataSupportsCachingAndBootstrapDiscovery(t *testing.T) {
|
|
handler, cleanup := testRouter(t)
|
|
defer cleanup()
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/api/client/reference-data/gpu", nil)
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, request)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("reference data returned %d: %s", response.Code, response.Body.String())
|
|
}
|
|
etag := response.Header().Get("ETag")
|
|
if etag == "" {
|
|
t.Fatal("reference data did not include an ETag")
|
|
}
|
|
var ranking map[string]any
|
|
if err := json.Unmarshal(response.Body.Bytes(), &ranking); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ranking["kind"] != "gpu" || ranking["items"] == nil {
|
|
t.Fatalf("unexpected reference data: %#v", ranking)
|
|
}
|
|
|
|
conditional := httptest.NewRequest(http.MethodGet, "/api/client/reference-data/gpu", nil)
|
|
conditional.Header.Set("If-None-Match", etag)
|
|
conditionalResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(conditionalResponse, conditional)
|
|
if conditionalResponse.Code != http.StatusNotModified {
|
|
t.Fatalf("conditional reference data returned %d", conditionalResponse.Code)
|
|
}
|
|
|
|
bootstrapRequest := httptest.NewRequest(http.MethodGet, "/api/client/bootstrap", nil)
|
|
bootstrapResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(bootstrapResponse, bootstrapRequest)
|
|
var bootstrap map[string]any
|
|
if err := json.Unmarshal(bootstrapResponse.Body.Bytes(), &bootstrap); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bootstrap["referenceData"] == nil {
|
|
t.Fatalf("bootstrap does not advertise reference data: %#v", bootstrap)
|
|
}
|
|
}
|
|
|
|
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_NAME_INVALID"},
|
|
} {
|
|
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(`<script type="module" src="/admin/assets/missing.js"></script>`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeAdminBuildMetadata(t, dir, "assets/missing.js")
|
|
status := adminassets.ValidateDisk(dir, "dev")
|
|
if status.Ready || !strings.Contains(status.ValidationError, "assets/missing.js") {
|
|
t.Fatalf("ValidateDisk returned %#v, want missing asset error", status)
|
|
}
|
|
}
|
|
|
|
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(`<script type="module" src="/admin/assets/missing.js"></script>`), 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)
|
|
}
|
|
writeAdminBuildMetadata(t, dir, "assets/missing.js")
|
|
r := &router{cfg: &config.Config{AdminWebDir: dir, AdminAssetMode: adminassets.ModeDisk}, adminAssets: adminassets.New(adminassets.ModeDisk, dir, "dev")}
|
|
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 writeAdminBuildMetadata(t *testing.T, dir, output string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, "admin-build.json"), []byte(`{"buildId":"dev"}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manifest := fmt.Sprintf(`{"src/main.ts":{"file":%q,"isEntry":true}}`, output)
|
|
if err := os.WriteFile(filepath.Join(dir, "asset-manifest.json"), []byte(manifest), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
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 TestAdminPageReadRoutesNeverReturnGatewayOrServerErrors(t *testing.T) {
|
|
handler, cleanup := testRouter(t)
|
|
defer cleanup()
|
|
session, _, err := loginForTest(handler)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
paths := []string{
|
|
"/api/admin/dashboard/overview?window=24h",
|
|
"/api/admin/feedbacks?page=1&pageSize=20",
|
|
"/api/admin/releases",
|
|
"/api/admin/releases/notices",
|
|
"/api/admin/legacy/update-info",
|
|
"/api/admin/legacy/media-types",
|
|
"/api/admin/sources",
|
|
"/api/admin/sources/check/status",
|
|
"/api/admin/endpoints",
|
|
"/api/admin/database/status",
|
|
"/api/admin/database/sync/jobs/latest",
|
|
"/api/admin/system/migration",
|
|
"/api/admin/system/branding",
|
|
"/api/admin/system/mail/config",
|
|
"/api/admin/sync/legacy/preview",
|
|
"/api/admin/system/health",
|
|
"/api/admin/system/audit?page=1&pageSize=20",
|
|
"/api/admin/system/logs?page=1&pageSize=20",
|
|
}
|
|
|
|
for _, path := range paths {
|
|
t.Run(path, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
|
res := httptest.NewRecorder()
|
|
handler.ServeHTTP(res, req)
|
|
if res.Code >= http.StatusInternalServerError {
|
|
t.Fatalf("admin page request returned %d: %s", res.Code, res.Body.String())
|
|
}
|
|
if res.Code == http.StatusUnauthorized || res.Code == http.StatusForbidden {
|
|
t.Fatalf("admin page request returned %d: %s", res.Code, res.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminDashboardSnapshotAndHealthExposeDiagnostics(t *testing.T) {
|
|
handler, cleanup := testRouter(t)
|
|
defer cleanup()
|
|
session, _, err := loginForTest(handler)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
request := func(path string) (*httptest.ResponseRecorder, map[string]any) {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
|
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)
|
|
}
|
|
return res, payload
|
|
}
|
|
|
|
first, dashboard := request("/api/admin/dashboard/overview?window=6h")
|
|
second, _ := request("/api/admin/dashboard/overview?window=6h")
|
|
for _, key := range []string{"sourceRows", "sourceCheckJobs", "generatedAt", "warnings", "heartbeats", "averageLatency", "clientCalls"} {
|
|
if _, ok := dashboard[key]; !ok {
|
|
t.Fatalf("dashboard missing %s: %#v", key, dashboard)
|
|
}
|
|
}
|
|
if first.Header().Get("X-Admin-Cache") != "miss" || second.Header().Get("X-Admin-Cache") != "hit" {
|
|
t.Fatalf("cache headers first=%q second=%q", first.Header().Get("X-Admin-Cache"), second.Header().Get("X-Admin-Cache"))
|
|
}
|
|
_, healthPayload := request("/api/admin/system/health")
|
|
assets, ok := healthPayload["adminAssets"].(map[string]any)
|
|
if !ok || assets["manifestStatus"] != "valid" || assets["buildId"] != "dev" {
|
|
t.Fatalf("admin asset diagnostics missing: %#v", healthPayload["adminAssets"])
|
|
}
|
|
}
|
|
|
|
func TestSecurityMiddlewareRecoversAPIPanicAsJSON(t *testing.T) {
|
|
handler := withSecurity(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
panic("simulated handler failure")
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/system/health", nil)
|
|
res := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(res, req)
|
|
|
|
if res.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want %d: %s", res.Code, http.StatusInternalServerError, res.Body.String())
|
|
}
|
|
if contentType := res.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") {
|
|
t.Fatalf("content type = %q, want JSON", contentType)
|
|
}
|
|
if !strings.Contains(res.Body.String(), "INTERNAL_SERVER_ERROR") {
|
|
t.Fatalf("response does not contain stable error code: %s", 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(`<!doctype html><link rel="stylesheet" href="/assets/portal.css"><script type="module" src="/assets/portal.js"></script>`), 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(`<!doctype html><link rel="stylesheet" href="/admin/assets/admin.css"><script type="module" src="/admin/assets/admin.js"></script>`), 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.WriteFile(filepath.Join(adminDist, "admin-build.json"), []byte(`{"buildId":"dev"}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(adminDist, "asset-manifest.json"), []byte(`{"src/main.ts":{"file":"assets/admin.js","css":["assets/admin.css"],"isEntry":true}}`), 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,
|
|
AdminAssetMode: adminassets.ModeDisk,
|
|
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)
|
|
}
|
|
}
|