398 lines
13 KiB
Go
398 lines
13 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: "List 噪声不推", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.ListInstances"}, wantOK: false},
|
|
{name: "空类型不推", event: parsedEvent{}, wantOK: false},
|
|
{name: "短名直接命中", event: parsedEvent{EventType: "LaunchInstance"}, wantOK: true,
|
|
want: map[string]string{"event": "LaunchInstance"}},
|
|
}
|
|
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", "instance"},
|
|
{"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)
|
|
}
|
|
}
|
|
}
|