初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// fakeWebhookEvents 记录 webhook handler 对回传服务的调用,便于断言分派行为。
|
||||
type fakeWebhookEvents struct {
|
||||
secret string
|
||||
cfgID uint
|
||||
confirmed []string
|
||||
ingested []ingestCall
|
||||
ingestErr error
|
||||
}
|
||||
|
||||
type ingestCall struct {
|
||||
cfgID uint
|
||||
messageID string
|
||||
payload string
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (f *fakeWebhookEvents) ResolveSecret(_ context.Context, secret string) (uint, bool) {
|
||||
if secret == f.secret {
|
||||
return f.cfgID, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (f *fakeWebhookEvents) ConfirmAsync(confirmURL string) {
|
||||
f.confirmed = append(f.confirmed, confirmURL)
|
||||
}
|
||||
|
||||
func (f *fakeWebhookEvents) Ingest(_ context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error {
|
||||
if f.ingestErr != nil {
|
||||
return f.ingestErr
|
||||
}
|
||||
f.ingested = append(f.ingested, ingestCall{cfgID, messageID, string(payload), truncated})
|
||||
return nil
|
||||
}
|
||||
|
||||
// newWebhookEnv 只挂 webhook 路由(与生产同链:Recovery+系统日志中间件),
|
||||
// 返回引擎、假回传服务与系统日志服务(断言留痕脱敏用)。
|
||||
func newWebhookEnv(t *testing.T) (*gin.Engine, *fakeWebhookEvents, *service.SystemLogService, *gorm.DB) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory sqlite: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db handle: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err := db.AutoMigrate(&model.SystemLog{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
fake := &fakeWebhookEvents{secret: strings.Repeat("ab", 32), cfgID: 7}
|
||||
systemLogs := service.NewSystemLogService(db)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/webhooks/oci-logs/:secret",
|
||||
gin.Recovery(), systemLogMiddleware(systemLogs), (&webhookHandler{events: fake}).handle)
|
||||
return r, fake, systemLogs, db
|
||||
}
|
||||
|
||||
// postWebhook 以 ONS 头发起一次投递请求。
|
||||
func postWebhook(t *testing.T, r *gin.Engine, secret, body string, headers map[string]string) int {
|
||||
t.Helper()
|
||||
req := httptestNewRequest(secret, body, headers)
|
||||
w := doRawRequest(r, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
func httptestNewRequest(secret, body string, headers map[string]string) *http.Request {
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/webhooks/oci-logs/"+secret, strings.NewReader(body))
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// doRawRequest 直接以 *http.Request 走引擎,便于携带任意头。
|
||||
func doRawRequest(r *gin.Engine, req *http.Request) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestWebhookDispatch(t *testing.T) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
stale := time.Now().Add(-10 * time.Minute).UTC().Format(time.RFC3339)
|
||||
oracleURL := "https://cell1.notification.ap-tokyo-1.oci.oraclecloud.com/confirm?token=x"
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
headers map[string]string
|
||||
body string
|
||||
wantStatus int
|
||||
wantConfirm int
|
||||
wantIngest int
|
||||
}{
|
||||
{
|
||||
name: "secret 不匹配 404", secret: "wrong",
|
||||
headers: map[string]string{onsHeaderMessageID: "m1"}, wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "订阅确认白名单内异步激活", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: oracleURL},
|
||||
wantStatus: http.StatusOK, wantConfirm: 1,
|
||||
},
|
||||
{
|
||||
name: "订阅确认白名单外拒绝", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: "https://evil.example.com/confirm"},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "订阅确认伪装 host 后缀拒绝", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: "https://eviloraclecloud.com/confirm"},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "订阅确认非 https 拒绝", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: "http://x.oraclecloud.com/confirm"},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "退订确认仅记录", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageType: msgTypeUnsubscribe},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "通知消息入库", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageID: "m1", onsHeaderTimestamp: now},
|
||||
body: `{"eventType":"t"}`,
|
||||
wantStatus: http.StatusOK, wantIngest: 1,
|
||||
},
|
||||
{
|
||||
name: "ONS 无冒号时区格式放行", secret: "",
|
||||
headers: map[string]string{
|
||||
onsHeaderMessageID: "m1",
|
||||
onsHeaderTimestamp: time.Now().UTC().Format("2006-01-02T15:04:05.999") + "+0000",
|
||||
},
|
||||
wantStatus: http.StatusOK, wantIngest: 1,
|
||||
},
|
||||
{
|
||||
name: "时间戳过期拒收", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageID: "m1", onsHeaderTimestamp: stale},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "时间戳非法拒收", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageID: "m1", onsHeaderTimestamp: "yesterday"},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "缺 MessageId 拒收", secret: "",
|
||||
headers: map[string]string{onsHeaderTimestamp: now},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "缺时间戳头放行入库", secret: "",
|
||||
headers: map[string]string{onsHeaderMessageID: "m2"},
|
||||
wantStatus: http.StatusOK, wantIngest: 1,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, fake, _, _ := newWebhookEnv(t)
|
||||
secret := tt.secret
|
||||
if secret == "" {
|
||||
secret = fake.secret
|
||||
}
|
||||
code := postWebhook(t, r, secret, tt.body, tt.headers)
|
||||
if code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", code, tt.wantStatus)
|
||||
}
|
||||
if len(fake.confirmed) != tt.wantConfirm {
|
||||
t.Errorf("confirm calls = %d, want %d", len(fake.confirmed), tt.wantConfirm)
|
||||
}
|
||||
if len(fake.ingested) != tt.wantIngest {
|
||||
t.Errorf("ingest calls = %d, want %d", len(fake.ingested), tt.wantIngest)
|
||||
}
|
||||
if tt.wantIngest > 0 && fake.ingested[0].cfgID != fake.cfgID {
|
||||
t.Errorf("ingest cfg = %d, want %d", fake.ingested[0].cfgID, fake.cfgID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookBodyTruncation(t *testing.T) {
|
||||
r, fake, _, _ := newWebhookEnv(t)
|
||||
big := strings.Repeat("x", webhookBodyLimit+100)
|
||||
code := postWebhook(t, r, fake.secret, big, map[string]string{onsHeaderMessageID: "m-big"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", code)
|
||||
}
|
||||
got := fake.ingested[0]
|
||||
if !got.truncated || len(got.payload) != webhookBodyLimit {
|
||||
t.Errorf("truncated=%v len=%d, want true/%d", got.truncated, len(got.payload), webhookBodyLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookSystemLogHidesSecret(t *testing.T) {
|
||||
r, fake, systemLogs, db := newWebhookEnv(t)
|
||||
postWebhook(t, r, fake.secret, "{}", map[string]string{onsHeaderMessageID: "m1"})
|
||||
systemLogs.Wait()
|
||||
var entry model.SystemLog
|
||||
if err := db.First(&entry).Error; err != nil {
|
||||
t.Fatalf("system log row: %v", err)
|
||||
}
|
||||
if strings.Contains(entry.Path, fake.secret) {
|
||||
t.Errorf("system log path leaks secret: %s", entry.Path)
|
||||
}
|
||||
if !strings.Contains(entry.Path, ":secret") {
|
||||
t.Errorf("path = %s, want route template with :secret", entry.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLogPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "webhook 路径脱敏末段", in: "/api/v1/webhooks/oci-logs/abcdef123456", want: "/api/v1/webhooks/oci-logs/***"},
|
||||
{name: "非 webhook 路径原样", in: "/api/v1/tasks/3", want: "/api/v1/tasks/3"},
|
||||
{name: "webhook 前缀本身原样", in: "/api/v1/webhooks/", want: "/api/v1/webhooks/"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeLogPath(tt.in); got != tt.want {
|
||||
t.Errorf("sanitizeLogPath(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user