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) } }) } }