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: "失败", }, } 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) } }) } }