package oci import ( "context" "encoding/json" "errors" "fmt" "reflect" "strings" "testing" "time" "github.com/oracle/oci-go-sdk/v65/audit" "github.com/oracle/oci-go-sdk/v65/common" "github.com/oracle/oci-go-sdk/v65/loggingsearch" ) // quotaZeroErr 复刻 Search 配额为零租户的真实报错(SDK 解析错误体失败后带原文)。 var quotaZeroErr = errors.New(`search audit logs: Failed to parse json from response body due to: json: cannot unmarshal number into Go struct field servicefailure.code of type string. With response body { "code" : 500, "message" : "Rate limit exceeded for ocid: ocid1.tenancy..x, maxQueriesPerMinute: 0, maxConcurrentQueries: 0" }.`) func TestIsSearchQuotaZero(t *testing.T) { cases := []struct { name string err error want bool }{ {"配额为零真实报错", quotaZeroErr, true}, {"普通限流不回退", errors.New(`Rate limit exceeded for ocid: x, maxQueriesPerMinute: 60, maxConcurrentQueries: 2`), false}, {"其他错误", errors.New("service unavailable"), false}, {"nil", nil, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := isSearchQuotaZero(tc.err); got != tc.want { t.Fatalf("isSearchQuotaZero() = %v, want %v", got, tc.want) } }) } } func TestListAuditBatchFallback(t *testing.T) { et := time.Now().UTC().Add(-10 * time.Minute) searchCalls, auditCalls := 0, 0 f := auditFetchers{ search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { searchCalls++ return nil, "", quotaZeroErr }, audit: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) { auditCalls++ if cur.M != auditModeFallback || cur.Page != "" { t.Fatalf("回退通道应携带模式标记且清空页游标, got %+v", cur) } ev := AuditEvent{EventId: fmt.Sprint(auditCalls), EventName: "GetInstance", EventTime: &et} return []AuditEvent{ev}, "", nil }, } res, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 3) if err != nil { t.Fatalf("配额为零应回退成功, got %v", err) } if searchCalls != 1 { t.Fatalf("Search 只应试错一次, got %d", searchCalls) } if len(res.Items) < 3 || auditCalls < 3 { t.Fatalf("回退后应继续凑批, items=%d auditCalls=%d", len(res.Items), auditCalls) } if res.Cursor == nil || res.Cursor.M != auditModeFallback || res.Cursor.WindowHours != auditFallbackWindowHours { t.Fatalf("续查游标应保持回退模式与基准窗宽, got %+v", res.Cursor) } } func TestListAuditBatchFallbackCursorSkipsSearch(t *testing.T) { et := time.Now().UTC().Add(-10 * time.Minute) f := auditFetchers{ search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { t.Fatal("回退模式游标不应再调用 Search 通道") return nil, "", nil }, audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { return []AuditEvent{{EventId: "e1", EventName: "GetVcn", EventTime: &et}}, "", nil }, } cur := NewAuditCursor(time.Now()).toFallback() if _, err := listAuditBatch(context.Background(), f, cur, 1); err != nil { t.Fatalf("回退模式续查失败: %v", err) } } func TestListAuditBatchSearchErrorNoFallback(t *testing.T) { f := auditFetchers{ search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { return nil, "", errors.New("search audit logs: timeout") }, audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { t.Fatal("普通错误不应触发回退") return nil, "", nil }, } if _, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 1); err == nil { t.Fatal("普通错误应原样上抛") } } func TestFilterAuditTerm(t *testing.T) { login := AuditEvent{EventId: "e1", EventName: "InteractiveLogin"} noise := AuditEvent{EventId: "e2", EventName: "ListRecommendations"} items := []AuditEvent{login, noise} cases := []struct { name string cur AuditCursor want int }{ {"无关键字原样放行", AuditCursor{}, 2}, {"Search 主路也精筛可见字段", AuditCursor{Q: "login"}, 1}, {"回退模式精筛", AuditCursor{Q: "login", M: auditModeFallback}, 1}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := filterAuditTerm(append([]AuditEvent{}, items...), tc.cur); len(got) != tc.want { t.Fatalf("filterAuditTerm() 保留 %d 条, want %d", len(got), tc.want) } }) } } func TestListAuditBatchSearchTermPrecision(t *testing.T) { et := time.Now().UTC().Add(-10 * time.Minute) // 模拟 Search 主路粗筛后仍混入的隐藏元数据误命中(如 ttype:login) f := auditFetchers{ search: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) { return []AuditEvent{ {EventId: "hit", EventName: "InteractiveLogin", EventTime: &et}, {EventId: "noise1", EventName: "ListRecommendations", EventTime: &et}, {EventId: "noise2", EventName: "SearchLogs", EventTime: &et}, }, "", nil }, audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { t.Fatal("Search 正常时不应走回退") return nil, "", nil }, } cur := NewAuditCursor(time.Now()) cur.Q = "login" res, err := listAuditBatch(context.Background(), f, cur, 1) if err != nil { t.Fatalf("listAuditBatch() err = %v", err) } if len(res.Items) != 1 || res.Items[0].EventId != "hit" { t.Fatalf("应只保留可见字段命中的事件, got %+v", res.Items) } } func TestMatchesAuditTerm(t *testing.T) { ev := AuditEvent{EventName: "ListVnicAttachments", ResourceName: "web-1", PrincipalName: "Vivien", IPAddress: "1.2.3.4"} cases := []struct { name string q string want bool }{ {"不区分大小写", "listvnic", true}, {"通配分段都出现", "List*Attachments", true}, {"资源名命中", "WEB-1", true}, {"未命中", "TerminateInstance", false}, {"通配缺段不命中", "List*Volume", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := matchesAuditTerm(ev, tc.q); got != tc.want { t.Fatalf("matchesAuditTerm(%q) = %v, want %v", tc.q, got, tc.want) } }) } } // searchResultFromJSON 把 JSON 文本构造成 SearchLogs 单条结果(Data 为 interface{})。 func searchResultFromJSON(t *testing.T, s string) loggingsearch.SearchResult { t.Helper() var v interface{} if err := json.Unmarshal([]byte(s), &v); err != nil { t.Fatalf("fixture 不是合法 JSON: %v", err) } return loggingsearch.SearchResult{Data: &v} } func TestToSearchAuditEvent(t *testing.T) { eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC) tests := []struct { name string data string wantOK bool want AuditEvent }{ { name: "全字段齐全", data: `{"datetime":1783074600000,"logContent":{ "id":"evt-abc","time":"2026-07-06T10:30:00Z","source":"ComputeApi", "data":{"eventName":"TerminateInstance","resourceName":"web-1","compartmentName":"prod", "identity":{"principalName":"api-admin","ipAddress":"1.2.3.4"}, "request":{"action":"DELETE","path":"/20160918/instances/ocid1..."}, "response":{"status":"204"}}}}`, wantOK: true, want: AuditEvent{ EventId: "evt-abc", EventTime: &eventTime, EventName: "TerminateInstance", Source: "ComputeApi", ResourceName: "web-1", CompartmentName: "prod", PrincipalName: "api-admin", IPAddress: "1.2.3.4", Status: "204", RequestAction: "DELETE", RequestPath: "/20160918/instances/ocid1...", }, }, { name: "identity/request/response 为 null 时只保留信封字段", data: `{"logContent":{"id":"evt-x","time":"2026-07-06T10:30:00Z","source":"VcnApi", "data":{"eventName":"GetVcn","identity":null,"request":null,"response":null}}}`, wantOK: true, want: AuditEvent{EventId: "evt-x", EventTime: &eventTime, Source: "VcnApi", EventName: "GetVcn"}, }, { name: "缺 logContent 丢弃", data: `{"datetime":1783074600000}`, wantOK: false, }, { name: "logContent 结构不符丢弃", data: `{"logContent":"plain-text"}`, wantOK: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := toSearchAuditEvent(searchResultFromJSON(t, tt.data)) if ok != tt.wantOK { t.Fatalf("ok = %v, want %v", ok, tt.wantOK) } if !ok { return } if len(got.Raw) == 0 { t.Fatalf("Raw 应携带 logContent 原文") } if !auditEventEqual(got, tt.want) { t.Errorf("toSearchAuditEvent() = %+v, want %+v", got, tt.want) } }) } } func TestToSearchAuditEventNilData(t *testing.T) { if _, ok := toSearchAuditEvent(loggingsearch.SearchResult{}); ok { t.Fatal("Data 为 nil 应丢弃") } } func TestToAuditEvent(t *testing.T) { eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC) tests := []struct { name string ev audit.AuditEvent want AuditEvent }{ { name: "全字段齐全", ev: audit.AuditEvent{ EventId: common.String("evt-abc"), Source: common.String("ComputeApi"), EventTime: &common.SDKTime{Time: eventTime}, Data: &audit.Data{ EventName: common.String("TerminateInstance"), ResourceName: common.String("web-1"), CompartmentName: common.String("prod"), Identity: &audit.Identity{ PrincipalName: common.String("api-admin"), IpAddress: common.String("1.2.3.4"), }, Request: &audit.Request{ Action: common.String("DELETE"), Path: common.String("/20160918/instances/ocid1..."), }, Response: &audit.Response{Status: common.String("204")}, }, }, want: AuditEvent{ EventId: "evt-abc", EventTime: &eventTime, EventName: "TerminateInstance", Source: "ComputeApi", ResourceName: "web-1", CompartmentName: "prod", PrincipalName: "api-admin", IPAddress: "1.2.3.4", Status: "204", RequestAction: "DELETE", RequestPath: "/20160918/instances/ocid1...", }, }, { name: "嵌套局部 nil 各自安全跳过", ev: audit.AuditEvent{ Data: &audit.Data{ EventName: common.String("GetInstance"), Request: &audit.Request{Path: common.String("/instances")}, }, }, want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"}, }, {name: "空事件全部零值", ev: audit.AuditEvent{}, want: AuditEvent{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := toAuditEvent(tt.ev); !auditEventEqual(got, tt.want) { t.Errorf("toAuditEvent() = %+v, want %+v", got, tt.want) } }) } } func TestAuditSearchQuery(t *testing.T) { const prefix = `search "ocid1.tenancy.oc1..aaa/_Audit" | where data.eventName != 'SummarizeMetricsData'` cases := []struct { name string term string want string }{ {"无关键字", "", prefix + ` | sort by datetime desc`}, {"带关键字追加全文匹配", "TerminateInstance", prefix + ` and logContent = '*TerminateInstance*' | sort by datetime desc`}, {"引号与反斜杠被消毒", `O'Brien\"x`, prefix + ` and logContent = '*OBrienx*' | sort by datetime desc`}, {"纯引号消毒后为空不追加", `'"`, prefix + ` | sort by datetime desc`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := auditSearchQuery("ocid1.tenancy.oc1..aaa", tc.term); got != tc.want { t.Fatalf("auditSearchQuery() = %q, want %q", got, tc.want) } }) } } func TestSanitizeAuditTerm(t *testing.T) { if got := SanitizeAuditTerm(" Get*Instance\t "); got != "Get*Instance" { t.Fatalf("应保留 * 并去除首尾空白与控制字符, got %q", got) } long := strings.Repeat("a", 300) if got := SanitizeAuditTerm(long); len(got) != auditTermMaxLen { t.Fatalf("超长应截断到 %d, got %d", auditTermMaxLen, len(got)) } } // auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。 func auditEventEqual(a, b AuditEvent) bool { if (a.EventTime == nil) != (b.EventTime == nil) { return false } if a.EventTime != nil && !a.EventTime.Equal(*b.EventTime) { return false } a.EventTime, b.EventTime = nil, nil a.Raw, b.Raw = nil, nil return reflect.DeepEqual(a, b) } func TestSortAuditEvents(t *testing.T) { t1 := time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC) t2 := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC) items := []AuditEvent{ {EventName: "old", EventTime: &t1}, {EventName: "no-time", EventTime: nil}, {EventName: "new", EventTime: &t2}, } sortAuditEvents(items) got := []string{items[0].EventName, items[1].EventName, items[2].EventName} want := []string{"new", "old", "no-time"} for i := range want { if got[i] != want[i] { t.Fatalf("sortAuditEvents() order = %v, want %v", got, want) } } } func TestKeepAuditEvent(t *testing.T) { cases := []struct { name string event AuditEvent keep bool }{ {"公网IP的普通事件保留", AuditEvent{EventName: "CreateUser", IPAddress: "203.0.113.7"}, true}, {"SummarizeMetricsData 噪声排除", AuditEvent{EventName: "SummarizeMetricsData", IPAddress: "203.0.113.7"}, false}, {"内网10段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "10.1.2.3"}, false}, {"内网172.16段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "172.20.0.1"}, false}, {"172.15不属内网保留", AuditEvent{EventName: "ListInstances", IPAddress: "172.15.0.1"}, true}, {"内网192.168段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "192.168.1.1"}, false}, {"CGNAT 100.64段排除", AuditEvent{EventName: "ListInstances", IPAddress: "100.100.0.1"}, false}, {"100.128不属CGNAT保留", AuditEvent{EventName: "ListInstances", IPAddress: "100.128.0.1"}, true}, {"IP为空保留(控制面事件)", AuditEvent{EventName: "TerminateInstance", IPAddress: ""}, true}, {"IP不可解析保留", AuditEvent{EventName: "ListInstances", IPAddress: "not-an-ip"}, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := keepAuditEvent(tc.event); got != tc.keep { t.Fatalf("keepAuditEvent(%+v) = %v, want %v", tc.event, got, tc.keep) } }) } } func TestAuditCursorAdvance(t *testing.T) { now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) base := AuditCursor{ Start: now.Add(-24 * time.Hour), End: now, WindowHours: 24, Q: "kw", } cases := []struct { name string cur AuditCursor empty bool wantHours int wantDone bool }{ {"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false}, {"空窗倍增", base, true, 48, false}, {"倍增封顶 336h(14 天查询窗硬限)", AuditCursor{Start: base.Start, End: base.End, WindowHours: 256}, true, 336, false}, {"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false}, {"回退模式有事件重置 1h 基准窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 8, M: auditModeFallback}, false, 1, false}, {"回退模式空窗照常倍增", AuditCursor{Start: base.Start, End: base.End, WindowHours: 1, M: auditModeFallback}, true, 2, false}, {"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { next, done := tc.cur.advance(now, tc.empty) if done != tc.wantDone { t.Fatalf("done = %v, want %v", done, tc.wantDone) } if done { return } if next.WindowHours != tc.wantHours { t.Fatalf("WindowHours = %d, want %d", next.WindowHours, tc.wantHours) } if !next.End.Equal(tc.cur.Start) { t.Fatalf("新窗 End = %v, 应紧邻上窗 Start %v", next.End, tc.cur.Start) } if got := next.End.Sub(next.Start); got != time.Duration(tc.wantHours)*time.Hour { t.Fatalf("窗宽 = %v, want %dh", got, tc.wantHours) } if next.Page != "" { t.Fatalf("新窗应清空窗内游标, got %q", next.Page) } if next.Q != tc.cur.Q { t.Fatalf("新窗应继承检索关键字, got %q want %q", next.Q, tc.cur.Q) } if next.M != tc.cur.M { t.Fatalf("新窗应继承通道模式, got %q want %q", next.M, tc.cur.M) } }) } }