283 lines
9.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|