Files
OpenFlare/internal/apps/openflare/pages/routers_source_test.go
T
deqiying 38b0516937 feat(pages): 支持 Remote 部署源同步
新增部署源配置与运行态模型、安全下载、租约续期、原子激活和失败补偿。

接入内部任务与脱敏前端交互,并阻止数据库 Trace 和日志展开敏感查询参数。
2026-07-19 17:36:51 +08:00

225 lines
7.6 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
)
type sourceHandlerEnvelope struct {
ErrorMsg string `json:"error_msg"`
Data json.RawMessage `json:"data"`
}
func newPagesSourceTestRouter(userID uint64) *gin.Engine {
router := testhelper.NewTestGinEngine(func(ctx *gin.Context) {
oauth.SetToContext(ctx, oauth.UserObjKey, &model.User{ID: userID})
ctx.Next()
})
router.GET("/api/v1/d/pages/:id/source", GetSourceHandler)
router.POST("/api/v1/d/pages/:id/source/update", UpdateSourceHandler)
router.POST("/api/v1/d/pages/:id/source/delete", DeleteSourceHandler)
router.POST("/api/v1/d/pages/:id/source/check", CheckSourceHandler)
router.POST("/api/v1/d/pages/:id/source/sync", SyncSourceHandler)
return router
}
func performPagesSourceRequest(
t *testing.T,
router http.Handler,
method string,
path string,
body []byte,
) (int, sourceHandlerEnvelope) {
t.Helper()
request := httptest.NewRequest(method, path, bytes.NewReader(body))
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
var envelope sourceHandlerEnvelope
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("json.Unmarshal(%s %s response %q) error = %v, want nil", method, path, recorder.Body.String(), err)
}
return recorder.Code, envelope
}
func setupPagesSourceDispatchTest(t *testing.T) {
t.Helper()
miniRedis, err := miniredis.Run()
if err != nil {
t.Fatalf("miniredis.Run() error = %v, want nil", err)
}
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
asynqClient := asynq.NewClient(asynq.RedisClientOpt{Addr: miniRedis.Addr()})
previousRedis := db.Redis
previousAsynqClient := task.AsynqClient
db.Redis = redisClient
task.AsynqClient = asynqClient
task.RegisterTaskMeta(PagesSourceActionMeta)
t.Cleanup(func() {
_ = asynqClient.Close()
_ = redisClient.Close()
miniRedis.Close()
task.AsynqClient = previousAsynqClient
db.Redis = previousRedis
})
}
func TestPagesSourceHandlersReturnStableActionErrors(t *testing.T) {
ctx := setupPagesSourceTest(t)
router := newPagesSourceTestRouter(42)
manualProject := mustCreatePagesSourceProject(t, ctx, "handler-no-source")
code, envelope := performPagesSourceRequest(
t,
router,
http.MethodPost,
fmt.Sprintf("/api/v1/d/pages/%d/source/sync", manualProject.ID),
nil,
)
if got, want := code, http.StatusNotFound; got != want {
t.Errorf("POST source/sync without source status = %d, want %d", got, want)
}
if got, want := envelope.ErrorMsg, errPagesSourceNotFound; got != want {
t.Errorf("POST source/sync without source error = %q, want %q", got, want)
}
remoteProject := mustCreatePagesSourceProject(t, ctx, "handler-check")
_, _ = mustConfigureRemoteSource(
t,
ctx,
remoteProject.ID,
"https://example.com/site.zip?token=handler-secret",
RemoteNetworkPolicyPublic,
)
code, envelope = performPagesSourceRequest(
t,
router,
http.MethodPost,
fmt.Sprintf("/api/v1/d/pages/%d/source/check", remoteProject.ID),
nil,
)
if got, want := code, http.StatusBadRequest; got != want {
t.Errorf("POST remote source/check status = %d, want %d", got, want)
}
if got, want := envelope.ErrorMsg, errPagesSourceCheckUnsupported; got != want {
t.Errorf("POST remote source/check error = %q, want %q", got, want)
}
if strings.Contains(string(envelope.Data), "handler-secret") || strings.Contains(envelope.ErrorMsg, "handler-secret") {
t.Errorf("POST remote source/check response = %+v, want no URL secret", envelope)
}
busyProject := mustCreatePagesSourceProject(t, ctx, "handler-busy")
busySource, _ := mustConfigureRemoteSource(
t,
ctx,
busyProject.ID,
"https://example.com/site.zip",
RemoteNetworkPolicyPublic,
)
future := time.Now().Add(time.Minute)
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", busySource.ID).
Updates(map[string]any{
"sync_status": pagesSourceStatusSyncing,
"lease_token": "busy-owner",
"lease_expires_at": &future,
}).Error; err != nil {
t.Fatalf("seed busy source runtime error = %v, want nil", err)
}
code, envelope = performPagesSourceRequest(
t,
router,
http.MethodPost,
fmt.Sprintf("/api/v1/d/pages/%d/source/sync", busyProject.ID),
[]byte(`{}`),
)
if got, want := code, http.StatusConflict; got != want {
t.Errorf("POST busy source/sync status = %d, want %d", got, want)
}
if got, want := envelope.ErrorMsg, errPagesSourceActionBusy; got != want {
t.Errorf("POST busy source/sync error = %q, want %q", got, want)
}
}
func TestSyncSourceHandlerAcceptsEmptyBodyAndEmptyObject(t *testing.T) {
ctx := setupPagesSourceTest(t)
setupPagesSourceDispatchTest(t)
router := newPagesSourceTestRouter(77)
project := mustCreatePagesSourceProject(t, ctx, "handler-empty-sync")
source, _ := mustConfigureRemoteSource(
t,
ctx,
project.ID,
"https://example.com/site.zip?token=dispatch-secret",
RemoteNetworkPolicyPublic,
)
path := fmt.Sprintf("/api/v1/d/pages/%d/source/sync", project.ID)
for _, test := range []struct {
name string
body []byte
}{
{name: "empty body", body: nil},
{name: "empty object", body: []byte(`{}`)},
} {
t.Run(test.name, func(t *testing.T) {
code, envelope := performPagesSourceRequest(t, router, http.MethodPost, path, test.body)
if got, want := code, http.StatusOK; got != want {
t.Fatalf("POST source/sync (%s) status = %d, want %d; error=%q", test.name, got, want, envelope.ErrorMsg)
}
if envelope.ErrorMsg != "" {
t.Errorf("POST source/sync (%s) error = %q, want empty", test.name, envelope.ErrorMsg)
}
var receipt SourceActionReceipt
if err := json.Unmarshal(envelope.Data, &receipt); err != nil {
t.Fatalf("json.Unmarshal(source/sync %s receipt) error = %v, want nil", test.name, err)
}
if receipt.TaskID == "" || receipt.ExecutionID == "" || receipt.Action != sourceActionSync {
t.Errorf("POST source/sync (%s) receipt = %+v, want task/execution IDs and action %q", test.name, receipt, sourceActionSync)
}
})
}
var executions []model.TaskExecution
if err := db.DB(ctx).Where("task_type = ?", PagesSourceActionTask).Order("id asc").Find(&executions).Error; err != nil {
t.Fatalf("list Pages source task executions error = %v, want nil", err)
}
if got, want := len(executions), 2; got != want {
t.Fatalf("Pages source task execution count = %d, want %d", got, want)
}
for _, execution := range executions {
if strings.Contains(execution.Payload, "dispatch-secret") || strings.Contains(execution.Payload, "http") {
t.Errorf("task execution %q payload = %s, want no Remote URL secret", execution.TaskID, execution.Payload)
}
var payload SourceActionPayload
if err := json.Unmarshal([]byte(execution.Payload), &payload); err != nil {
t.Errorf("json.Unmarshal(task execution %q payload) error = %v, want nil", execution.TaskID, err)
continue
}
if payload.SourceID != source.ID || payload.ConfigVersion != source.ConfigVersion || payload.Actor != "user:77" {
t.Errorf("task execution %q payload = %+v, want source=%d config=%d actor=user:77", execution.TaskID, payload, source.ID, source.ConfigVersion)
}
}
}