416 lines
13 KiB
Go
416 lines
13 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{},
|
|
&model.AlertRule{}, &model.AlertRuleHit{}); 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
|
|
}{
|
|
{
|
|
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",
|
|
},
|
|
}
|
|
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.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)
|
|
}
|
|
})
|
|
}
|
|
}
|