Files

410 lines
14 KiB
Go

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)
}
}
}