Files
wangdefa 7706f59549
CI / test (push) Successful in 32s
Release / release (push) Successful in 52s
发布 0.2.0:模型池自愈、探测修正、任务异步触发与删除加固
2026-07-10 20:25:37 +08:00

523 lines
17 KiB
Go

package service
import (
"context"
"errors"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
const testPEM = "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----"
// fakeClient 是 oci.Client 的测试替身,普通测试不访问真实 OCI。
// 内嵌接口以自动满足 oci.Client,未覆写的方法不会被这些测试调用。
type fakeClient struct {
oci.Client
tenancy oci.TenancyInfo
tenancyErr error
profile oci.AccountProfile
profileErr error
caps oci.AccountCapabilities
capsErr error
regionSubs []oci.RegionSubscription
regionSubsErr error
regionSubsCalls int
compartments []oci.Compartment
compartmentsErr error
compartmentsCalls int
subscribeErr error
limitValues []oci.LimitValue
limitsErr error
limitServices []oci.LimitService
subscriptions []oci.SubscriptionInfo
subDetail oci.SubscriptionDetail
subDetailErr error
instances []oci.Instance
instancesErr error
costItems []oci.CostItem
costErr error
instance oci.Instance
instanceErr error
image oci.Image
imageErr error
subscribedHomeRegion string
subscribedKey string
limitsQuery oci.LimitsQuery
subDetailID string
launched []oci.CreateInstanceInput
}
func (f *fakeClient) ValidateKey(ctx context.Context, cred oci.Credentials) (oci.TenancyInfo, error) {
return f.tenancy, f.tenancyErr
}
func (f *fakeClient) GetAccountCapabilities(ctx context.Context, cred oci.Credentials, region string) (oci.AccountCapabilities, error) {
return f.caps, f.capsErr
}
func (f *fakeClient) FetchAccountProfile(ctx context.Context, cred oci.Credentials) (oci.AccountProfile, error) {
return f.profile, f.profileErr
}
func (f *fakeClient) ListRegionSubscriptions(ctx context.Context, cred oci.Credentials) ([]oci.RegionSubscription, error) {
f.regionSubsCalls++
return f.regionSubs, f.regionSubsErr
}
func (f *fakeClient) ListCompartments(ctx context.Context, cred oci.Credentials) ([]oci.Compartment, error) {
f.compartmentsCalls++
return f.compartments, f.compartmentsErr
}
func (f *fakeClient) SubscribeRegion(ctx context.Context, cred oci.Credentials, homeRegion, regionKey string) error {
f.subscribedHomeRegion = homeRegion
f.subscribedKey = regionKey
return f.subscribeErr
}
func (f *fakeClient) ListLimits(ctx context.Context, cred oci.Credentials, q oci.LimitsQuery) ([]oci.LimitValue, error) {
f.limitsQuery = q
return f.limitValues, f.limitsErr
}
func (f *fakeClient) ListLimitServices(ctx context.Context, cred oci.Credentials, region string) ([]oci.LimitService, error) {
return f.limitServices, nil
}
func (f *fakeClient) ListSubscriptions(ctx context.Context, cred oci.Credentials) ([]oci.SubscriptionInfo, error) {
return f.subscriptions, nil
}
func (f *fakeClient) GetSubscription(ctx context.Context, cred oci.Credentials, subscriptionID string) (oci.SubscriptionDetail, error) {
f.subDetailID = subscriptionID
return f.subDetail, f.subDetailErr
}
func (f *fakeClient) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) {
f.launched = append(f.launched, in)
return oci.Instance{DisplayName: in.DisplayName, LifecycleState: "PROVISIONING", FreeformTags: in.FreeformTags}, nil
}
// ListAvailabilityDomains 默认单可用域,抢机自动轮询退化为固定 ad-1。
func (f *fakeClient) ListAvailabilityDomains(ctx context.Context, cred oci.Credentials, region string) ([]string, error) {
return []string{"fake-ad-1"}, nil
}
func (f *fakeClient) ListInstances(ctx context.Context, cred oci.Credentials, region string) ([]oci.Instance, error) {
return f.instances, f.instancesErr
}
func (f *fakeClient) GetInstance(ctx context.Context, cred oci.Credentials, region, instanceID string) (oci.Instance, error) {
return f.instance, f.instanceErr
}
func (f *fakeClient) GetImage(ctx context.Context, cred oci.Credentials, region, imageID string) (oci.Image, error) {
return f.image, f.imageErr
}
func (f *fakeClient) SummarizeCosts(ctx context.Context, cred oci.Credentials, q oci.CostQuery) ([]oci.CostItem, error) {
return f.costItems, f.costErr
}
func newTestService(t *testing.T, client oci.Client) *OciConfigService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open in-memory sqlite: %v", err)
}
// :memory: 库每条连接各自独立,后台 goroutine(模型验证等)触发第二条连接会看到空库;
// 收敛到单连接,与 tenantdelete 测试环境一致
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("database handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(
&model.OciConfig{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.Proxy{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cipher, err := crypto.NewCipher("test-data-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
return NewOciConfigService(db, cipher, client)
}
func trialImportInput() ImportInput {
return ImportInput{
Alias: "试用期",
Group: "教育",
TenancyOCID: "ocid1.tenancy.oc1..t",
UserOCID: "ocid1.user.oc1..u",
Region: "eu-frankfurt-1",
Fingerprint: "aa:bb",
PrivateKey: testPEM,
}
}
func TestImportAutoVerifiesAndClassifies(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"},
profile: oci.AccountProfile{
AccountType: model.AccountTypeTrial,
SubscriptionID: "ocid1.organizationssubscription.oc1..s",
PaymentModel: "FREE_TRIAL",
SubscriptionTier: "FREE_AND_TRIAL",
PromotionStatus: "ACTIVE",
PromotionAmount: 300,
},
}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if got, want := cfg.AliveStatus, model.AliveStatusAlive; got != want {
t.Errorf("AliveStatus = %q, want %q", got, want)
}
if got, want := cfg.AccountType, model.AccountTypeTrial; got != want {
t.Errorf("AccountType = %q, want %q", got, want)
}
if got, want := cfg.TenancyName, "mytenancy"; got != want {
t.Errorf("TenancyName = %q, want %q", got, want)
}
if got, want := cfg.Group, "教育"; got != want {
t.Errorf("Group = %q, want %q", got, want)
}
if cfg.LastVerifiedAt == nil {
t.Error("LastVerifiedAt = nil, want set")
}
if cfg.PrivateKeyEnc == testPEM || cfg.PrivateKeyEnc == "" {
t.Error("PrivateKeyEnc: private key stored without encryption")
}
}
func TestImportDeadKeyStillSaved(t *testing.T) {
client := &fakeClient{tenancyErr: errors.New("NotAuthenticated")}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if got, want := cfg.AliveStatus, model.AliveStatusDead; got != want {
t.Errorf("AliveStatus = %q, want %q", got, want)
}
if cfg.LastError == "" {
t.Error("LastError is empty, want failure message")
}
}
func TestImportParsesConfigINI(t *testing.T) {
client := &fakeClient{profileErr: errors.New("skip profile")}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), ImportInput{
Alias: "ini 导入",
ConfigINI: "[DEFAULT]\nuser=ocid1.user.oc1..u\ntenancy=ocid1.tenancy.oc1..t\nfingerprint=ff:ee\nregion=ap-tokyo-1\n",
PrivateKey: testPEM,
})
if err != nil {
t.Fatalf("Import: %v", err)
}
if got, want := cfg.Region, "ap-tokyo-1"; got != want {
t.Errorf("Region = %q, want %q", got, want)
}
if got, want := cfg.UserOCID, "ocid1.user.oc1..u"; got != want {
t.Errorf("UserOCID = %q, want %q", got, want)
}
}
func TestImportProfileFailureDegradesToUnknown(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "mytenancy"},
profileErr: errors.New("subscription api forbidden"),
}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if got, want := cfg.AliveStatus, model.AliveStatusAlive; got != want {
t.Errorf("AliveStatus = %q, want %q", got, want)
}
if got, want := cfg.AccountType, model.AccountTypeUnknown; got != want {
t.Errorf("AccountType = %q, want %q", got, want)
}
if cfg.LastError == "" {
t.Error("LastError is empty, want profile failure message")
}
}
func TestImportRejectsInvalidInput(t *testing.T) {
svc := newTestService(t, &fakeClient{})
in := trialImportInput()
in.PrivateKey = "not a pem"
if _, err := svc.Import(context.Background(), in); err == nil {
t.Error("Import with bad key: got nil error, want failure")
}
}
func TestVerifyDetectsChanges(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"},
profile: oci.AccountProfile{
AccountType: model.AccountTypeTrial,
PaymentModel: "FREE_TRIAL",
PromotionStatus: "ACTIVE",
},
}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
// 模拟云端状态变化:试用到期变免费
client.profile = oci.AccountProfile{
AccountType: model.AccountTypeFree,
PaymentModel: "FREE_TRIAL",
PromotionStatus: "EXPIRED",
}
updated, changes, err := svc.Verify(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got, want := updated.AccountType, model.AccountTypeFree; got != want {
t.Errorf("AccountType = %q, want %q", got, want)
}
if got, want := changes["accountType"], [2]string{model.AccountTypeTrial, model.AccountTypeFree}; got != want {
t.Errorf("changes[accountType] = %v, want %v", got, want)
}
if got, want := changes["promotionStatus"], [2]string{"ACTIVE", "EXPIRED"}; got != want {
t.Errorf("changes[promotionStatus] = %v, want %v", got, want)
}
}
func TestVerifyNoChanges(t *testing.T) {
client := &fakeClient{
tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"},
profile: oci.AccountProfile{AccountType: model.AccountTypeTrial, PaymentModel: "FREE_TRIAL"},
}
svc := newTestService(t, client)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
_, changes, err := svc.Verify(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if len(changes) != 0 {
t.Errorf("changes = %v, want empty", changes)
}
}
func TestVerifyMissingConfig(t *testing.T) {
svc := newTestService(t, &fakeClient{})
if _, _, err := svc.Verify(context.Background(), 999); err == nil {
t.Error("Verify(999): got nil error, want not found")
}
}
func TestDelete(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
migrateTenantDeleteModels(t, svc.db)
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if err := svc.Delete(context.Background(), cfg.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if err := svc.Delete(context.Background(), cfg.ID); err == nil {
t.Error("Delete twice: got nil error, want not found")
}
}
func TestUpdateFields(t *testing.T) {
const newPEM = "-----BEGIN PRIVATE KEY-----\nrotated\n-----END PRIVATE KEY-----"
empty := ""
prod := "生产"
tests := []struct {
name string
in UpdateInput
check func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig)
}{
{
name: "改别名与默认区域",
in: UpdateInput{Alias: "改名后", Region: "ap-tokyo-1"},
check: func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) {
if cfg.Alias != "改名后" || cfg.Region != "ap-tokyo-1" {
t.Errorf("alias/region = %q/%q, want 改名后/ap-tokyo-1", cfg.Alias, cfg.Region)
}
if cred, err := svc.credentialsOf(cfg); err != nil || cred.PrivateKey != testPEM {
t.Errorf("private key changed unexpectedly: %v", err)
}
},
},
{
name: "改分组",
in: UpdateInput{Group: &prod},
check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) {
if cfg.Group != "生产" {
t.Errorf("Group = %q, want 生产", cfg.Group)
}
},
},
{
name: "清空分组",
in: UpdateInput{Group: &empty},
check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) {
if cfg.Group != "" {
t.Errorf("Group = %q, want empty", cfg.Group)
}
},
},
{
name: "缺省分组保持不变",
in: UpdateInput{Alias: "只改名"},
check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) {
if cfg.Group != "教育" {
t.Errorf("Group = %q, want 教育 (unchanged)", cfg.Group)
}
},
},
{
name: "换私钥与指纹",
in: UpdateInput{PrivateKey: newPEM, Fingerprint: "cc:dd"},
check: func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) {
if cfg.Fingerprint != "cc:dd" {
t.Errorf("fingerprint = %q, want cc:dd", cfg.Fingerprint)
}
if cred, err := svc.credentialsOf(cfg); err != nil || cred.PrivateKey != newPEM {
t.Errorf("private key not rotated: %v", err)
}
},
},
{
name: "清除口令",
in: UpdateInput{Passphrase: &empty},
check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) {
if cfg.PassphraseEnc != "" {
t.Errorf("PassphraseEnc = %q, want empty", cfg.PassphraseEnc)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
updated, _, err := svc.Update(context.Background(), cfg.ID, tt.in)
if err != nil {
t.Fatalf("Update: %v", err)
}
if updated.LastVerifiedAt == nil || !updated.LastVerifiedAt.After(*cfg.LastVerifiedAt) {
t.Error("Update should re-verify: LastVerifiedAt not advanced")
}
tt.check(t, svc, updated)
})
}
}
func TestUpdateKeyRequiresFingerprint(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
cfg, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import: %v", err)
}
if _, _, err := svc.Update(context.Background(), cfg.ID, UpdateInput{PrivateKey: "x"}); err == nil {
t.Error("Update with key but no fingerprint: got nil error, want rejection")
}
}
func TestListReturnsSummaries(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "mytenancy"}})
first, err := svc.Import(context.Background(), trialImportInput())
if err != nil {
t.Fatalf("Import first: %v", err)
}
second := trialImportInput()
second.Alias = "生产号"
second.Group = ""
if _, err := svc.Import(context.Background(), second); err != nil {
t.Fatalf("Import second: %v", err)
}
items, err := svc.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 2 {
t.Fatalf("len(items) = %d, want 2", len(items))
}
got := items[0]
want := ConfigSummary{
ID: first.ID, Alias: "试用期", Group: "教育",
TenancyOCID: first.TenancyOCID, TenancyName: "mytenancy", Region: first.Region,
AccountType: first.AccountType, AliveStatus: model.AliveStatusAlive,
LastVerifiedAt: got.LastVerifiedAt, CreatedAt: got.CreatedAt,
}
if got != want {
t.Errorf("items[0] = %+v, want %+v", got, want)
}
if items[1].Group != "" || items[1].Alias != "生产号" {
t.Errorf("items[1] alias/group = %q/%q, want 生产号/empty", items[1].Alias, items[1].Group)
}
}
// TestVerifySuspendedOverridesStatus 云端暂停标记应覆盖测活结论为 suspended;
// 能力接口失败或未标记时不改变原结论。
func TestVerifySuspendedOverridesStatus(t *testing.T) {
client := &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
// 标记暂停:即使 ValidateKey 成功也应判 suspended
client.caps = oci.AccountCapabilities{Suspended: true, AccountStatus: "terminated"}
got, _, err := svc.Verify(ctx, cfg.ID)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.AliveStatus != model.AliveStatusSuspended {
t.Fatalf("AliveStatus = %q, want suspended", got.AliveStatus)
}
if got.LastError == "" {
t.Error("暂停时 LastError 应携带账户状态备注")
}
// 解除暂停:恢复 alive
client.caps = oci.AccountCapabilities{}
got, _, err = svc.Verify(ctx, cfg.ID)
if err != nil || got.AliveStatus != model.AliveStatusAlive {
t.Fatalf("解除暂停后 = %q, %v; want alive", got.AliveStatus, err)
}
// 能力接口失败:不影响测活结论
client.capsErr = errors.New("boom")
got, _, err = svc.Verify(ctx, cfg.ID)
if err != nil || got.AliveStatus != model.AliveStatusAlive {
t.Fatalf("能力接口失败后 = %q, %v; want alive", got.AliveStatus, err)
}
}