package service import ( "context" "errors" "strings" "sync" "testing" "time" "oci-portal/internal/crypto" "oci-portal/internal/model" "oci-portal/internal/oci" ) // fakeRelayClient 是可编程的回传链路假客户端;内嵌接口,未覆写方法不被调用。 // fail 按步骤名注入错误;calls 记录动作序列供断言编排与回滚顺序。 type fakeRelayClient struct { oci.Client mu sync.Mutex calls []string fail map[string]error subStates []string // GetRelaySubscription 依次返回的状态,超出取末位 subIdx int endpoint string // 最近一次订阅创建收到的 endpoint connectorTimeout bool // 模拟 Connector 已建但轮询超时 state oci.RelayState } func (f *fakeRelayClient) step(name string) error { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, name) return f.fail[name] } func (f *fakeRelayClient) EnsureRelayTopic(context.Context, oci.Credentials) (oci.RelayResource, error) { if err := f.step("topic"); err != nil { return oci.RelayResource{}, err } return oci.RelayResource{ID: "t1", State: "ACTIVE", Created: true}, nil } func (f *fakeRelayClient) EnsureRelaySubscription(_ context.Context, _ oci.Credentials, topicID, endpoint string) (oci.RelayResource, error) { f.mu.Lock() f.endpoint = endpoint f.mu.Unlock() if err := f.step("sub"); err != nil { return oci.RelayResource{}, err } return oci.RelayResource{ID: "s1", State: "PENDING", Created: true}, nil } func (f *fakeRelayClient) GetRelaySubscription(context.Context, oci.Credentials, string) (oci.RelayResource, error) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, "getsub") state := "ACTIVE" if len(f.subStates) > 0 { i := f.subIdx if i >= len(f.subStates) { i = len(f.subStates) - 1 } state = f.subStates[i] f.subIdx++ } return oci.RelayResource{ID: "s1", State: state}, nil } func (f *fakeRelayClient) EnsureRelayPolicy(_ context.Context, _ oci.Credentials, homeRegion string) (oci.RelayResource, error) { if err := f.step("policy:" + homeRegion); err != nil { return oci.RelayResource{}, err } return oci.RelayResource{ID: "p1", State: "ACTIVE", Created: true}, nil } func (f *fakeRelayClient) EnsureRelayConnector(context.Context, oci.Credentials, string, string) (oci.RelayResource, error) { if err := f.step("connector"); err != nil { return oci.RelayResource{}, err } if f.connectorTimeout { return oci.RelayResource{ID: "c1", State: "CREATING", Created: true}, errors.New("service connector not active after 90s") } return oci.RelayResource{ID: "c1", State: "ACTIVE", Created: true}, nil } func (f *fakeRelayClient) RelayState(context.Context, oci.Credentials, string) (oci.RelayState, error) { if err := f.step("state"); err != nil { return oci.RelayState{}, err } return f.state, nil } func (f *fakeRelayClient) DeleteRelayConnector(context.Context, oci.Credentials, string) error { return f.step("del-connector") } func (f *fakeRelayClient) DeleteRelayPolicy(context.Context, oci.Credentials, string, string) error { return f.step("del-policy") } func (f *fakeRelayClient) DeleteRelaySubscription(context.Context, oci.Credentials, string) error { return f.step("del-sub") } func (f *fakeRelayClient) DeleteRelayTopic(context.Context, oci.Credentials, string) error { return f.step("del-topic") } // deletes 过滤出删除动作序列,断言回滚/销毁顺序。 func (f *fakeRelayClient) deletes() []string { f.mu.Lock() defer f.mu.Unlock() var out []string for _, c := range f.calls { if strings.HasPrefix(c, "del-") { out = append(out, c) } } return out } // newRelayEnv 在 logevent 环境上补齐 relay 依赖:可解密凭据 + 假客户端 + 短轮询。 func newRelayEnv(t *testing.T, fc *fakeRelayClient) (*LogEventService, uint) { t.Helper() svc, db, cfgID := newLogEventEnv(t) cipher, err := crypto.NewCipher("test-key") if err != nil { t.Fatalf("new cipher: %v", err) } enc, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----") if err != nil { t.Fatalf("encrypt key: %v", err) } var cfg model.OciConfig if err := db.First(&cfg, cfgID).Error; err != nil { t.Fatalf("load config: %v", err) } cfg.PrivateKeyEnc = enc cfg.TenancyOCID = "ocid1.tenancy.oc1..t" cfg.UserOCID = "ocid1.user.oc1..u" cfg.Region = "us-ashburn-1" cfg.Fingerprint = "aa:bb" cfg.HomeRegionKey = "IAD" if err := db.Save(&cfg).Error; err != nil { t.Fatalf("seed credentials: %v", err) } svc.SetRelayDeps(NewOciConfigService(db, cipher, fc), fc, "https://demo.example.com/") svc.relayPollTick = time.Millisecond svc.relayPollTimeout = 30 * time.Millisecond return svc, cfgID } func TestSetupRelayHappyPath(t *testing.T) { fc := &fakeRelayClient{state: oci.RelayState{ Topic: oci.RelayResource{ID: "t1", State: "ACTIVE"}, Subscription: oci.RelayResource{ID: "s1", State: "ACTIVE"}, Connector: oci.RelayResource{ID: "c1", State: "ACTIVE"}, Policy: oci.RelayResource{ID: "p1", State: "ACTIVE"}, }} svc, cfgID := newRelayEnv(t, fc) view, err := svc.SetupRelay(context.Background(), cfgID) if err != nil { t.Fatalf("SetupRelay: %v", err) } if !view.Ready { t.Errorf("Ready = false, want true; view = %+v", view) } if view.Webhook == nil || !strings.HasPrefix(view.Endpoint, "https://demo.example.com/api/v1/webhooks/oci-logs/") { t.Errorf("endpoint = %q, want 公网基址拼接", view.Endpoint) } if len(view.Events) != len(RelayCriticalEvents) { t.Errorf("events = %d 项, want %d", len(view.Events), len(RelayCriticalEvents)) } if dels := fc.deletes(); len(dels) != 0 { t.Errorf("成功路径不应有删除调用,got %v", dels) } // 编排顺序:Topic → 订阅(endpoint 已拼接)→ 确认轮询 → Policy(home region)→ Connector joined := strings.Join(fc.calls, ",") for _, frag := range []string{"topic", "sub", "getsub", "policy:IAD", "connector"} { if !strings.Contains(joined, frag) { t.Errorf("calls = %s, 缺少 %s", joined, frag) } } if !strings.HasPrefix(fc.endpoint, "https://demo.example.com/api/v1/webhooks/oci-logs/") { t.Errorf("订阅 endpoint = %q, want 公网基址拼接", fc.endpoint) } } func TestSetupRelayRollback(t *testing.T) { tests := []struct { name string program func(fc *fakeRelayClient) wantErr string wantDelSeq []string }{ { name: "订阅创建失败回滚 Topic", program: func(fc *fakeRelayClient) { fc.fail = map[string]error{"sub": nil} }, wantErr: "创建订阅", wantDelSeq: []string{"del-topic"}, }, { name: "Policy 失败回滚订阅与 Topic", program: func(fc *fakeRelayClient) { fc.fail = map[string]error{"policy:IAD": errors.New("boom")} }, wantErr: "创建 Policy", wantDelSeq: []string{"del-sub", "del-topic"}, }, { name: "Connector 创建失败回滚全部前置", program: func(fc *fakeRelayClient) { fc.fail = map[string]error{"connector": errors.New("boom")} }, wantErr: "创建 Connector", wantDelSeq: []string{"del-policy", "del-sub", "del-topic"}, }, { name: "Connector 超时时连自身一并回滚", program: func(fc *fakeRelayClient) { fc.connectorTimeout = true }, wantErr: "创建 Connector", wantDelSeq: []string{"del-connector", "del-policy", "del-sub", "del-topic"}, }, { name: "订阅确认超时回滚订阅与 Topic", program: func(fc *fakeRelayClient) { fc.subStates = []string{"PENDING"} }, wantErr: "订阅确认超时", wantDelSeq: []string{"del-sub", "del-topic"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fc := &fakeRelayClient{} tt.program(fc) // map 值为 nil 的注入替换为真实错误(subtest 里统一) for k, v := range fc.fail { if v == nil { fc.fail[k] = errors.New("boom") } } svc, cfgID := newRelayEnv(t, fc) _, err := svc.SetupRelay(context.Background(), cfgID) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("err = %v, want 含 %q", err, tt.wantErr) } dels := fc.deletes() if strings.Join(dels, ",") != strings.Join(tt.wantDelSeq, ",") { t.Errorf("回滚序列 = %v, want %v", dels, tt.wantDelSeq) } }) } } func TestSetupRelayRequiresPublicURL(t *testing.T) { svc, _, cfgID := newLogEventEnv(t) if _, err := svc.SetupRelay(context.Background(), cfgID); !errors.Is(err, ErrRelayNotConfigured) { t.Errorf("err = %v, want ErrRelayNotConfigured", err) } } func TestTeardownRelay(t *testing.T) { fc := &fakeRelayClient{state: oci.RelayState{ Topic: oci.RelayResource{ID: "t1", State: "ACTIVE"}, Subscription: oci.RelayResource{ID: "s1", State: "ACTIVE"}, Connector: oci.RelayResource{ID: "c1", State: "ACTIVE"}, Policy: oci.RelayResource{ID: "p1", State: "ACTIVE"}, }} svc, cfgID := newRelayEnv(t, fc) ctx := context.Background() if _, err := svc.EnsureSecret(ctx, cfgID); err != nil { t.Fatalf("ensure secret: %v", err) } if err := svc.TeardownRelay(ctx, cfgID); err != nil { t.Fatalf("TeardownRelay: %v", err) } want := []string{"del-connector", "del-policy", "del-sub", "del-topic"} if dels := fc.deletes(); strings.Join(dels, ",") != strings.Join(want, ",") { t.Errorf("销毁序列 = %v, want %v", dels, want) } if _, exists, err := svc.SecretInfo(ctx, cfgID); err != nil || exists { t.Errorf("teardown 后 secret 仍存在 (exists=%v, err=%v)", exists, err) } } func TestRelayReady(t *testing.T) { active := func() oci.RelayState { return oci.RelayState{ Topic: oci.RelayResource{ID: "t", State: "ACTIVE"}, Subscription: oci.RelayResource{ID: "s", State: "ACTIVE"}, Connector: oci.RelayResource{ID: "c", State: "ACTIVE"}, Policy: oci.RelayResource{ID: "p", State: "ACTIVE"}, } } tests := []struct { name string secret bool mutate func(*oci.RelayState) want bool }{ {name: "全链路活跃", secret: true, mutate: func(*oci.RelayState) {}, want: true}, {name: "secret 未生成", secret: false, mutate: func(*oci.RelayState) {}, want: false}, {name: "订阅 PENDING", secret: true, mutate: func(s *oci.RelayState) { s.Subscription.State = "PENDING" }, want: false}, {name: "Connector 缺失", secret: true, mutate: func(s *oci.RelayState) { s.Connector = oci.RelayResource{} }, want: false}, {name: "Policy 缺失", secret: true, mutate: func(s *oci.RelayState) { s.Policy = oci.RelayResource{} }, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { st := active() tt.mutate(&st) if got := relayReady(tt.secret, st); got != tt.want { t.Errorf("relayReady = %v, want %v", got, tt.want) } }) } } func TestCriticalEventText(t *testing.T) { tests := []struct { name string event parsedEvent wantOK bool want map[string]string }{ { name: "实例终止含操作者与成败", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance", ResourceName: "vm-1", Actor: "demo@example.com", SourceIP: "203.0.113.8", Outcome: "成功"}, wantOK: true, want: map[string]string{"event": "TerminateInstance", "resource": "vm-1", "actor": "demo@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""}, }, { name: "字段缺失兜底破折号", event: parsedEvent{EventType: "com.oraclecloud.IdentityControlPlane.CreateApiKey"}, wantOK: true, want: map[string]string{"event": "CreateApiKey", "resource": "—", "actor": "—", "ip": "—", "outcome": "—", "detail": ""}, }, { name: "补充说明独立成行", event: parsedEvent{EventType: "CreatePolicy", Detail: "允许发布到 ONS Topic"}, wantOK: true, want: map[string]string{"event": "CreatePolicy", "detail": "\n允许发布到 ONS Topic"}, }, { name: "begin 阶段剥后缀命中并回退 source 作资源", event: parsedEvent{EventType: "com.oraclecloud.computeApi.TerminateInstance.begin", Source: "instance-20260717-1445", Actor: "IT Team", SourceIP: "137.131.7.136", Outcome: "成功"}, wantOK: true, want: map[string]string{"event": "TerminateInstance", "resource": "instance-20260717-1445", "actor": "IT Team", "ip": "137.131.7.136", "outcome": "成功"}, }, {name: "end 阶段不重复告警", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance.end"}, wantOK: false}, {name: "LaunchInstance 已移出清单不告警", event: parsedEvent{EventType: "com.oraclecloud.computeApi.LaunchInstance.begin"}, wantOK: false}, {name: "List 噪声不推", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.ListInstances"}, wantOK: false}, {name: "空类型不推", event: parsedEvent{}, wantOK: false}, {name: "短名直接命中", event: parsedEvent{EventType: "InstanceAction"}, wantOK: true, want: map[string]string{"event": "InstanceAction"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { vars, ok := criticalEventVars("免费01", tt.event) if ok != tt.wantOK { t.Fatalf("ok = %v, want %v", ok, tt.wantOK) } if !ok { return } if vars["tenant"] != "免费01" { t.Errorf("tenant = %q, want 免费01", vars["tenant"]) } for k, want := range tt.want { if vars[k] != want { t.Errorf("%s = %q, want %q", k, vars[k], want) } } }) } } func TestRelayEventClass(t *testing.T) { cases := []struct { name string class string }{ {"LaunchInstance", ""}, // 已移出回传清单,不归类 {"TerminateInstance", "instance"}, {"InstanceAction", "instance"}, {"CreateUser", "identity"}, {"DeleteApiKey", "identity"}, {"UpdateUserCapabilities", "identity"}, {"CreatePolicy", "policy"}, {"DeletePolicy", "policy"}, {"CreateRegionSubscription", "region"}, {"InteractiveLogin", "login"}, {"FederatedInteractiveLogin", "login"}, {"ListInstances", ""}, } for _, tc := range cases { if got := relayEventClass(tc.name); got != tc.class { t.Errorf("relayEventClass(%s) = %q, want %q", tc.name, got, tc.class) } } // 关键事件清单里的每个事件都必须有归属子类,防新增事件漏配开关 for _, e := range RelayCriticalEvents { if relayEventClass(e) == "" { t.Errorf("critical event %s has no notify class", e) } } }