Files
2026-07-09 19:18:04 +08:00

283 lines
9.0 KiB
Go

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: "SigningCertURL 非 Oracle 域名拒绝", secret: "",
headers: map[string]string{
onsHeaderMessageID: "m-evil-cert",
onsHeaderTimestamp: now,
onsHeaderSigningCertURL: "https://evil.example.com/cert.crt",
},
body: `{"eventType":"t"}`,
wantStatus: http.StatusForbidden,
},
{
name: "SigningCertURL 非 https 拒绝", secret: "",
headers: map[string]string{
onsHeaderMessageID: "m-http-cert",
onsHeaderTimestamp: now,
onsHeaderSigningCertURL: "http://objectstorage.eu-paris-1.oraclecloud.com/cert.crt",
},
body: `{"eventType":"t"}`,
wantStatus: http.StatusForbidden,
},
{
name: "SigningCertURL 白名单内放行(canonical form 未接入,不做签名校验)", secret: "",
headers: map[string]string{
onsHeaderMessageID: "m-good-cert",
onsHeaderTimestamp: now,
onsHeaderSigningCertURL: "https://objectstorage.eu-paris-1.oraclecloud.com/p/x/n/bmc-ons-prod/b/ons-ds/o/sig.crt",
},
body: `{"eventType":"t"}`,
wantStatus: http.StatusOK, wantIngest: 1,
},
{
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)
}
})
}
}