457 lines
16 KiB
Go
457 lines
16 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|