package service import ( "context" "encoding/json" "errors" "testing" "time" "oci-portal/internal/oci" ) // auditStubClient 覆写审计查询并记录透传参数,其余行为沿用 fakeClient。 type auditStubClient struct { *fakeClient result oci.AuditEventsResult gotRegion string gotStart time.Time gotEnd time.Time gotPage string calls int } func (f *auditStubClient) ListAuditEvents(ctx context.Context, cred oci.Credentials, region string, start, end time.Time, page string) (oci.AuditEventsResult, error) { f.calls++ f.gotRegion, f.gotStart, f.gotEnd, f.gotPage = region, start, end, page return f.result, nil } func TestAuditEventsHoursValidation(t *testing.T) { tests := []struct { name string hours int wantErr bool }{ {name: "下界 1 小时", hours: 1}, {name: "默认 24 小时", hours: 24}, {name: "上界 72 小时", hours: 72}, {name: "0 越界", hours: 0, wantErr: true}, {name: "负数越界", hours: -3, wantErr: true}, {name: "100 越界", hours: 100, wantErr: true}, } client := &auditStubClient{ fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, result: oci.AuditEventsResult{Items: []oci.AuditEvent{{EventName: "GetInstance"}}}, } svc := newTestService(t, client) cfg := importAliveConfig(t, svc) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := svc.AuditEvents(context.Background(), cfg.ID, AuditQuery{Hours: tt.hours}) if tt.wantErr { if !errors.Is(err, ErrInvalidAuditHours) { t.Fatalf("AuditEvents(hours=%d) error = %v, want ErrInvalidAuditHours", tt.hours, err) } return } if err != nil { t.Fatalf("AuditEvents(hours=%d): %v", tt.hours, err) } if len(got.Items) != 1 { t.Errorf("items = %d, want 1", len(got.Items)) } }) } } func TestAuditEventsWindowAndRegion(t *testing.T) { client := &auditStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}} svc := newTestService(t, client) cfg := importAliveConfig(t, svc) got, err := svc.AuditEvents(context.Background(), cfg.ID, AuditQuery{Region: "ap-tokyo-1", Hours: 6}) if err != nil { t.Fatalf("AuditEvents: %v", err) } if client.calls != 1 { t.Fatalf("calls = %d, want 1", client.calls) } if client.gotRegion != "ap-tokyo-1" { t.Errorf("region = %q, want %q", client.gotRegion, "ap-tokyo-1") } if window := client.gotEnd.Sub(client.gotStart); window != 6*time.Hour { t.Errorf("window = %v, want %v", window, 6*time.Hour) } // 响应回传分钟粒度的绝对窗,续查据此原样带回 if got.Start.Second() != 0 || !got.End.After(got.Start) { t.Errorf("响应窗口 = [%v, %v), 应为分钟粒度且有序", got.Start, got.End) } } func TestAuditEventsResume(t *testing.T) { client := &auditStubClient{ fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, result: oci.AuditEventsResult{Items: []oci.AuditEvent{}, Truncated: true, NextPage: "tok-2"}, } svc := newTestService(t, client) cfg := importAliveConfig(t, svc) ctx := context.Background() // 续查:绝对窗 + 游标透传;NextPage 原样回传 got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{ Start: "2026-07-08T10:00:00Z", End: "2026-07-09T10:00:00Z", Page: "tok-1", }) if err != nil { t.Fatalf("续查: %v", err) } if client.gotPage != "tok-1" || !got.Truncated || got.NextPage != "tok-2" { t.Errorf("游标透传 page=%q next=%q truncated=%v", client.gotPage, got.NextPage, got.Truncated) } if !client.gotStart.Equal(time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC)) { t.Errorf("续查未用绝对窗: start=%v", client.gotStart) } // 非法窗口逐项拒绝 bad := []AuditQuery{ {Start: "not-a-time", End: "2026-07-09T10:00:00Z"}, {Start: "2026-07-09T10:00:00Z", End: "2026-07-08T10:00:00Z"}, // 倒序 {Start: "2026-07-01T00:00:00Z", End: "2026-07-09T10:00:00Z"}, // 超 72h {Page: "tok-only"}, // 带游标缺窗口 } for i, q := range bad { if _, err := svc.AuditEvents(ctx, cfg.ID, q); !errors.Is(err, ErrInvalidAuditWindow) { t.Errorf("bad[%d] err = %v, want ErrInvalidAuditWindow", i, err) } } } func TestAuditRawStrippedAndDetail(t *testing.T) { raw := json.RawMessage(`{"eventId":"evt-1","full":true}`) eventTime := time.Date(2026, 7, 9, 10, 30, 40, 0, time.UTC) client := &auditStubClient{ fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, result: oci.AuditEventsResult{Items: []oci.AuditEvent{ {EventId: "evt-1", EventTime: &eventTime, EventName: "TerminateInstance", Raw: raw}, }}, } svc := newTestService(t, client) cfg := importAliveConfig(t, svc) ctx := context.Background() got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{Hours: 1}) if err != nil { t.Fatalf("AuditEvents: %v", err) } if got.Items[0].Raw != nil { t.Error("列表响应应剥离 raw") } // 详情走缓存,不再回源 detail, err := svc.AuditEventDetail(ctx, cfg.ID, "", "evt-1", eventTime) if err != nil || string(detail) != string(raw) { t.Fatalf("AuditEventDetail = %s, %v", detail, err) } if client.calls != 1 { t.Errorf("缓存命中不应回源, calls = %d", client.calls) } } func TestAuditDetailRequeryFallback(t *testing.T) { raw := json.RawMessage(`{"eventId":"evt-2"}`) eventTime := time.Date(2026, 7, 9, 10, 30, 40, 0, time.UTC) client := &auditStubClient{ fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, result: oci.AuditEventsResult{Items: []oci.AuditEvent{ {EventId: "evt-2", EventTime: &eventTime, Raw: raw}, }}, } svc := newTestService(t, client) cfg := importAliveConfig(t, svc) ctx := context.Background() // 缓存未预热:miss 走事件时间小窗重查找回 detail, err := svc.AuditEventDetail(ctx, cfg.ID, "", "evt-2", eventTime) if err != nil || string(detail) != string(raw) { t.Fatalf("兜底重查 = %s, %v", detail, err) } if client.calls != 1 { t.Fatalf("应回源一次, calls = %d", client.calls) } if win := client.gotEnd.Sub(client.gotStart); win != 2*time.Minute || client.gotStart.Second() != 0 { t.Errorf("兜底窗口 = [%v, +%v), 应为事件分钟起 2 分钟", client.gotStart, win) } // 重查也找不到 → ErrAuditEventGone if _, err := svc.AuditEventDetail(ctx, cfg.ID, "", "no-such", eventTime); !errors.Is(err, ErrAuditEventGone) { t.Errorf("未找回 err = %v, want ErrAuditEventGone", err) } }