Files
oci-portal/internal/service/logevent_test.go
T

453 lines
15 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/model"
)
// newLogEventEnv 建含回传所需表的内存库环境,并预置一个 OCI 配置。
func newLogEventEnv(t *testing.T) (*LogEventService, *gorm.DB, uint) {
t.Helper()
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)
}
// :memory: 库每个连接彼此独立,后台协程写库需复用同一连接
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}, &model.LogEvent{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cfg := model.OciConfig{Alias: "测试租户"}
if err := db.Create(&cfg).Error; err != nil {
t.Fatalf("create config: %v", err)
}
return NewLogEventService(db), db, cfg.ID
}
func TestEnsureAndResolveSecret(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
other := model.OciConfig{Alias: "另一租户"}
if err := db.Create(&other).Error; err != nil {
t.Fatalf("create config: %v", err)
}
first, err := svc.EnsureSecret(ctx, cfgID)
if err != nil {
t.Fatalf("ensure secret: %v", err)
}
if len(first.Secret) != 64 {
t.Fatalf("secret length = %d, want 64", len(first.Secret))
}
if want := "/api/v1/webhooks/oci-logs/" + first.Secret; first.Path != want {
t.Errorf("path = %s, want %s", first.Path, want)
}
again, err := svc.EnsureSecret(ctx, cfgID)
if err != nil || again.Secret != first.Secret {
t.Errorf("ensure not idempotent: %v, secret changed %v", err, again.Secret != first.Secret)
}
otherInfo, err := svc.EnsureSecret(ctx, other.ID)
if err != nil {
t.Fatalf("ensure other secret: %v", err)
}
tests := []struct {
name string
secret string
wantID uint
wantHit bool
}{
{name: "命中本租户", secret: first.Secret, wantID: cfgID, wantHit: true},
{name: "命中另一租户", secret: otherInfo.Secret, wantID: other.ID, wantHit: true},
{name: "未知 secret 不命中", secret: "deadbeef", wantHit: false},
{name: "空 secret 不命中", secret: "", wantHit: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, ok := svc.ResolveSecret(ctx, tt.secret)
if ok != tt.wantHit || id != tt.wantID {
t.Errorf("resolve = (%d,%v), want (%d,%v)", id, ok, tt.wantID, tt.wantHit)
}
})
}
if err := svc.RevokeSecret(ctx, cfgID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, ok := svc.ResolveSecret(ctx, first.Secret); ok {
t.Error("revoked secret still resolves")
}
if _, exists, _ := svc.SecretInfo(ctx, cfgID); exists {
t.Error("secret info exists after revoke")
}
}
func TestEnsureSecretRejectsUnknownConfig(t *testing.T) {
svc, db, _ := newLogEventEnv(t)
if _, err := svc.EnsureSecret(context.Background(), 9999); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("ensure unknown config error = %v, want record not found", err)
}
var count int64
db.Model(&model.Setting{}).Where("key = ?", secretKey(9999)).Count(&count)
if count != 0 {
t.Fatalf("unknown config secret rows = %d, want 0", count)
}
}
func TestEnsureSecretConcurrentDeleteNoOrphan(t *testing.T) {
svc, db := newConcurrentSecretEnv(t)
for i := 0; i < 8; i++ {
cfg := model.OciConfig{Alias: fmt.Sprintf("race-%d", i)}
if err := db.Create(&cfg).Error; err != nil {
t.Fatalf("create config: %v", err)
}
ensureErr, deleteErr := runEnsureDeleteRace(svc, db, cfg.ID)
if deleteErr != nil {
t.Fatalf("delete config %d: %v", cfg.ID, deleteErr)
}
if ensureErr != nil && !errors.Is(ensureErr, gorm.ErrRecordNotFound) {
t.Fatalf("ensure config %d: %v", cfg.ID, ensureErr)
}
assertNoSecretSetting(t, db, cfg.ID)
}
}
func newConcurrentSecretEnv(t *testing.T) (*LogEventService, *gorm.DB) {
t.Helper()
dsn := filepath.Join(t.TempDir(), "secret.db") + "?_txlock=immediate&_pragma=busy_timeout%3d5000"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db handle: %v", err)
}
sqlDB.SetMaxOpenConns(4)
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return NewLogEventService(db), db
}
func runEnsureDeleteRace(svc *LogEventService, db *gorm.DB, cfgID uint) (error, error) {
start := make(chan struct{})
ensureDone, deleteDone := make(chan error, 1), make(chan error, 1)
go func() {
<-start
_, err := svc.EnsureSecret(context.Background(), cfgID)
ensureDone <- err
}()
go func() {
<-start
deleteDone <- deleteConfigAndSecret(db, cfgID)
}()
close(start)
return <-ensureDone, <-deleteDone
}
func deleteConfigAndSecret(db *gorm.DB, cfgID uint) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := lockOciConfig(tx, cfgID); err != nil {
return err
}
if err := tx.Delete(&model.Setting{}, "key = ?", secretKey(cfgID)).Error; err != nil {
return err
}
return tx.Delete(&model.OciConfig{}, cfgID).Error
})
}
func assertNoSecretSetting(t *testing.T, db *gorm.DB, cfgID uint) {
t.Helper()
var count int64
db.Model(&model.Setting{}).Where("key = ?", secretKey(cfgID)).Count(&count)
if count != 0 {
t.Fatalf("orphan secret rows for config %d = %d", cfgID, count)
}
}
func TestResolveSecretRejectsOrphanSetting(t *testing.T) {
svc, db, _ := newLogEventEnv(t)
orphan := model.Setting{Key: secretKey(9999), Value: "orphan-secret"}
if err := db.Create(&orphan).Error; err != nil {
t.Fatalf("create orphan secret: %v", err)
}
if id, ok := svc.ResolveSecret(context.Background(), orphan.Value); ok || id != 0 {
t.Fatalf("resolve orphan secret = (%d,%v), want (0,false)", id, ok)
}
info, ok, err := svc.SecretInfo(context.Background(), 9999)
if !errors.Is(err, gorm.ErrRecordNotFound) || ok || info.Secret != "" {
t.Fatalf("orphan secret info = (%q,%v,%v), want empty,false,record not found", info.Secret, ok, err)
}
}
func TestIngestIdempotent(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
for i := 0; i < 2; i++ {
if err := svc.Ingest(ctx, cfgID, "msg-1", []byte(`{"eventType":"t"}`), false); err != nil {
t.Fatalf("ingest #%d: %v", i+1, err)
}
}
if err := svc.Ingest(ctx, cfgID, "msg-2", []byte("cut"), true); err != nil {
t.Fatalf("ingest truncated: %v", err)
}
var total int64
db.Model(&model.LogEvent{}).Count(&total)
if total != 2 {
t.Errorf("rows = %d, want 2(重复 messageId 不落新行)", total)
}
var truncated model.LogEvent
db.First(&truncated, "message_id = ?", "msg-2")
if !truncated.Truncated {
t.Error("truncated flag not persisted")
}
}
func TestIngestRejectsUnknownConfig(t *testing.T) {
svc, db, _ := newLogEventEnv(t)
err := svc.Ingest(context.Background(), 9999, "orphan", []byte(`{}`), false)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("ingest unknown config error = %v, want record not found", err)
}
var count int64
db.Model(&model.LogEvent{}).Where("message_id = ?", "orphan").Count(&count)
if count != 0 {
t.Fatalf("orphan event rows = %d, want 0", count)
}
}
func TestParseLogEvent(t *testing.T) {
ts := "2026-07-07T08:00:00Z"
tests := []struct {
name string
payload string
wantType string
wantSource string
wantIP string
wantTime bool
wantActor string
wantOutcome string
wantDetail string
}{
{
name: "CloudEvents 单事件",
payload: `{"eventType":"com.oraclecloud.ComputeApi.TerminateInstance","source":"ComputeApi","eventTime":"` + ts + `"}`,
wantType: "com.oraclecloud.ComputeApi.TerminateInstance",
wantSource: "ComputeApi",
wantTime: true,
},
{
name: "type 别名",
payload: `{"type":"custom.event"}`,
wantType: "custom.event",
},
{
name: "外层缺失取 data 内层",
payload: `{"data":{"eventType":"inner.event","source":"Audit"}}`,
wantType: "inner.event",
wantSource: "Audit",
},
{
name: "数组取首个",
payload: `[{"eventType":"batch.first"},{"eventType":"batch.second"}]`,
wantType: "batch.first",
},
{name: "非 JSON 全空", payload: "plain text message"},
{name: "时间非法只丢时间", payload: `{"eventType":"x","eventTime":"not-a-time"}`, wantType: "x"},
{
name: "Audit 事件提取 data.identity.ipAddress",
payload: `{"eventType":"com.oraclecloud.limits.ListLimitValues","data":{"identity":{"ipAddress":"203.0.113.9"}}}`,
wantType: "com.oraclecloud.limits.ListLimitValues",
wantIP: "203.0.113.9",
},
{
name: "identity 缺失 IP 为空",
payload: `{"eventType":"x","data":{"identity":{}}}`,
wantType: "x",
},
{
name: "Audit v2 提取操作者成败与策略描述",
payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{
"identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"},
"message":"ociportal-logs-sch CreatePolicy succeeded",
"stateChange":{"current":{"description":"允许 Connector 发布到 ONS"}}}}`,
wantType: "com.oraclecloud.identityControlPlane.CreatePolicy", wantIP: "129.159.43.9",
wantActor: "Alfonso Garcia", wantOutcome: "成功", wantDetail: "允许 Connector 发布到 ONS",
},
{
name: "IDCS 登录失败提取用户 IP 与原因",
payload: `{"eventType":"com.oraclecloud.IdentitySignOn.InteractiveLogin","data":{
"additionalDetails":{"actorName":"oci","clientIp":"155.117.82.111",
"auditEventMapValue":"{\"eventId\":\"sso.authentication.failure\",\"message\":\"Authentication failure : incorrect password.\"}"}}}`,
wantType: "com.oraclecloud.IdentitySignOn.InteractiveLogin", wantIP: "155.117.82.111",
wantActor: "oci", wantOutcome: "失败", wantDetail: "Authentication failure : incorrect password.",
},
{
name: "message failed 后缀判失败",
payload: `{"eventType":"x","data":{"message":"vm TerminateInstance failed"}}`,
wantType: "x",
wantOutcome: "失败",
},
{
name: "failed with response 判失败并提取错误码",
payload: `{"type":"com.oraclecloud.computeApi.LaunchInstance.begin","data":{
"message":"LaunchInstance failed with response 'NotAuthorizedOrNotFound'"}}`,
wantType: "com.oraclecloud.computeApi.LaunchInstance.begin",
wantOutcome: "失败",
wantDetail: "NotAuthorizedOrNotFound",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseLogEvent([]byte(tt.payload))
if got.EventType != tt.wantType || got.Source != tt.wantSource || got.SourceIP != tt.wantIP {
t.Errorf("parse = (%q,%q,%q), want (%q,%q,%q)",
got.EventType, got.Source, got.SourceIP, tt.wantType, tt.wantSource, tt.wantIP)
}
if got.Actor != tt.wantActor || got.Outcome != tt.wantOutcome || got.Detail != tt.wantDetail {
t.Errorf("推送字段 = (%q,%q,%q), want (%q,%q,%q)",
got.Actor, got.Outcome, got.Detail, tt.wantActor, tt.wantOutcome, tt.wantDetail)
}
if (got.EventTime != nil) != tt.wantTime {
t.Errorf("eventTime present = %v, want %v", got.EventTime != nil, tt.wantTime)
}
})
}
}
func TestParseOnceMarksProcessed(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
if err := svc.Ingest(ctx, cfgID, "m-json", []byte(`{"eventType":"a.b","source":"S"}`), false); err != nil {
t.Fatalf("ingest: %v", err)
}
if err := svc.Ingest(ctx, cfgID, "m-text", []byte("opaque"), false); err != nil {
t.Fatalf("ingest: %v", err)
}
svc.parseOnce(ctx)
var parsed, opaque model.LogEvent
db.First(&parsed, "message_id = ?", "m-json")
db.First(&opaque, "message_id = ?", "m-text")
if !parsed.Processed || parsed.EventType != "a.b" || parsed.Source != "S" {
t.Errorf("parsed = %+v, want processed with type/source", parsed)
}
if !opaque.Processed || opaque.EventType != "" {
t.Errorf("opaque = %+v, want processed without fields", opaque)
}
}
func TestUpdateParsedEventDoesNotRecreateDeleted(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
event := model.LogEvent{OciConfigID: cfgID, MessageID: "deleted", Payload: `{}`}
if err := db.Create(&event).Error; err != nil {
t.Fatalf("create event: %v", err)
}
if err := db.Delete(&model.LogEvent{}, event.ID).Error; err != nil {
t.Fatalf("delete event: %v", err)
}
if svc.updateParsedEvent(context.Background(), &event, parsedEvent{EventType: "x"}) {
t.Fatal("deleted event update reported success")
}
var count int64
db.Model(&model.LogEvent{}).Where("id = ?", event.ID).Count(&count)
if count != 0 {
t.Fatalf("deleted event was recreated, rows = %d", count)
}
}
func TestUpdateParsedEventOnlyOnce(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
event := model.LogEvent{OciConfigID: cfgID, MessageID: "once", Payload: `{}`}
if err := db.Create(&event).Error; err != nil {
t.Fatalf("create event: %v", err)
}
parsed := parsedEvent{EventType: "x"}
if !svc.updateParsedEvent(context.Background(), &event, parsed) {
t.Fatal("first conditional update should succeed")
}
if svc.updateParsedEvent(context.Background(), &event, parsed) {
t.Fatal("processed event should not update twice")
}
}
func TestLogEventCleanup(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
old := model.LogEvent{OciConfigID: cfgID, MessageID: "old", ReceivedAt: time.Now().Add(-100 * 24 * time.Hour)}
db.Create(&old)
for i := 0; i < 4; i++ {
db.Create(&model.LogEvent{
OciConfigID: cfgID, MessageID: fmt.Sprintf("new-%d", i), ReceivedAt: time.Now(),
})
}
if err := svc.cleanup(ctx, logEventRetention, 2); err != nil {
t.Fatalf("cleanup: %v", err)
}
var total int64
db.Model(&model.LogEvent{}).Count(&total)
if total != 2 {
t.Errorf("rows after cleanup = %d, want 2(过期删除+超量删最旧)", total)
}
var gone model.LogEvent
if err := db.First(&gone, "message_id = ?", "old").Error; err == nil {
t.Error("expired row survived cleanup")
}
}
func TestLogEventListFilters(t *testing.T) {
svc, db, cfgID := newLogEventEnv(t)
ctx := context.Background()
other := model.OciConfig{Alias: "旁租户"}
db.Create(&other)
for i := 0; i < 3; i++ {
db.Create(&model.LogEvent{OciConfigID: cfgID, MessageID: fmt.Sprintf("a-%d", i), ReceivedAt: time.Now()})
}
db.Create(&model.LogEvent{OciConfigID: other.ID, MessageID: "b-0", ReceivedAt: time.Now()})
tests := []struct {
name string
query LogEventQuery
wantTotal int64
wantLen int
wantFirst string
}{
{name: "全部倒序", query: LogEventQuery{}, wantTotal: 4, wantLen: 4, wantFirst: "b-0"},
{name: "按租户过滤", query: LogEventQuery{CfgID: cfgID}, wantTotal: 3, wantLen: 3, wantFirst: "a-2"},
{name: "分页第二页", query: LogEventQuery{CfgID: cfgID, Page: 2, PageSize: 2}, wantTotal: 3, wantLen: 1, wantFirst: "a-0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
items, total, err := svc.List(ctx, tt.query)
if err != nil {
t.Fatalf("list: %v", err)
}
if total != tt.wantTotal || len(items) != tt.wantLen {
t.Fatalf("total=%d len=%d, want %d/%d", total, len(items), tt.wantTotal, tt.wantLen)
}
if items[0].MessageID != tt.wantFirst {
t.Errorf("first = %s, want %s", items[0].MessageID, tt.wantFirst)
}
})
}
}